package wallet import ( "context" "database/sql" "errors" "math" "strconv" "time" "game-admin/internal/models" "github.com/google/uuid" "github.com/uptrace/bun" ) const ( StatusReserved = "reserved" StatusConfirmed = "confirmed" StatusCanceled = "canceled" StatusAlreadyConfirmed = "already_confirmed" StatusAlreadyCanceled = "already_canceled" ) type Balances struct { Available int64 Reserved int64 } type Service struct { db *bun.DB } func NewService(db *bun.DB) *Service { return &Service{db: db} } func (s *Service) Reserve(ctx context.Context, requestID, userIDRaw, gameIDRaw, roundID string, amount int64, currency string) (*models.Transaction, string, Balances, error) { if amount <= 0 { return nil, "", Balances{}, ErrInvalidAmount } if requestID == "" || userIDRaw == "" || gameIDRaw == "" || roundID == "" { return nil, "", Balances{}, ErrMissingRequiredFields } userID, err := strconv.ParseInt(userIDRaw, 10, 64) if err != nil { return nil, "", Balances{}, ErrInvalidUserID } gameID, err := strconv.ParseInt(gameIDRaw, 10, 64) if err != nil { return nil, "", Balances{}, ErrInvalidGameID } var transaction *models.Transaction var balances Balances status := StatusReserved err = s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { existing := new(models.Transaction) err := tx.NewSelect(). Model(existing). Where("request_id = ?", requestID). Scan(ctx) if err == nil { transaction = existing balances, err = s.getBalancesTx(ctx, tx, existing.UserID, existing.GameID) return err } if !errors.Is(err, sql.ErrNoRows) { return err } userGame, err := s.getUserGameTx(ctx, tx, userID, gameID, true) if err != nil { return err } available := moneyToMinorUnits(userGame.Balance) reserved, err := s.sumReservedTx(ctx, tx, userID, gameID) if err != nil { return err } if available < amount { return ErrInsufficientFunds } _, err = tx.NewUpdate(). Model((*models.UserGame)(nil)). Set("balance = ?", minorUnitsToMoney(available-amount)). Set("updated_at = ?", time.Now()). Where("id = ?", userGame.ID). Exec(ctx) if err != nil { return err } transaction = &models.Transaction{ ID: uuid.NewString(), RequestID: requestID, UserID: userID, GameID: gameID, RoundID: roundID, Amount: amount, WinAmount: 0, Currency: currency, Status: StatusReserved, CreatedAt: time.Now(), } if _, err := tx.NewInsert().Model(transaction).Exec(ctx); err != nil { return err } balances = Balances{ Available: available - amount, Reserved: reserved + amount, } return nil }) return transaction, status, balances, err } func (s *Service) Confirm(ctx context.Context, transactionID, roundID string, winAmount int64) (*models.Transaction, string, Balances, error) { if transactionID == "" { return nil, "", Balances{}, ErrMissingTransactionID } if roundID == "" { return nil, "", Balances{}, ErrMissingRoundID } if winAmount < 0 { return nil, "", Balances{}, ErrInvalidWinAmount } var transaction *models.Transaction var balances Balances status := StatusConfirmed err := s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { current, err := s.getTransactionTx(ctx, tx, transactionID, true) if err != nil { return err } if current.RoundID != roundID { return ErrRoundMismatch } userGame, err := s.getUserGameTx(ctx, tx, current.UserID, current.GameID, true) if err != nil { return err } switch current.Status { case StatusConfirmed: status = StatusAlreadyConfirmed case StatusCanceled: return ErrAlreadyCanceled case StatusReserved: available := moneyToMinorUnits(userGame.Balance) _, err = tx.NewUpdate(). Model((*models.UserGame)(nil)). Set("balance = ?", minorUnitsToMoney(available+winAmount)). Set("updated_at = ?", time.Now()). Where("id = ?", userGame.ID). Exec(ctx) if err != nil { return err } current.WinAmount = winAmount current.Status = StatusConfirmed if _, err := tx.NewUpdate(). Model(current). Column("win_amount", "status"). WherePK(). Exec(ctx); err != nil { return err } default: return ErrInvalidTransactionState } transaction = current balances, err = s.getBalancesTx(ctx, tx, current.UserID, current.GameID) return err }) return transaction, status, balances, err } func (s *Service) Cancel(ctx context.Context, transactionID string) (*models.Transaction, string, Balances, error) { if transactionID == "" { return nil, "", Balances{}, ErrMissingTransactionID } var transaction *models.Transaction var balances Balances status := StatusCanceled err := s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { current, err := s.getTransactionTx(ctx, tx, transactionID, true) if err != nil { return err } userGame, err := s.getUserGameTx(ctx, tx, current.UserID, current.GameID, true) if err != nil { return err } switch current.Status { case StatusCanceled: status = StatusAlreadyCanceled case StatusConfirmed: return ErrAlreadyConfirmed case StatusReserved: available := moneyToMinorUnits(userGame.Balance) _, err = tx.NewUpdate(). Model((*models.UserGame)(nil)). Set("balance = ?", minorUnitsToMoney(available+current.Amount)). Set("updated_at = ?", time.Now()). Where("id = ?", userGame.ID). Exec(ctx) if err != nil { return err } current.Status = StatusCanceled if _, err := tx.NewUpdate(). Model(current). Column("status"). WherePK(). Exec(ctx); err != nil { return err } default: return ErrInvalidTransactionState } transaction = current balances, err = s.getBalancesTx(ctx, tx, current.UserID, current.GameID) return err }) return transaction, status, balances, err } func (s *Service) GetTransaction(ctx context.Context, transactionID string) (*models.Transaction, error) { if transactionID == "" { return nil, ErrMissingTransactionID } return s.getTransactionTx(ctx, s.db, transactionID, false) } func (s *Service) getTransactionTx(ctx context.Context, db bun.IDB, transactionID string, lock bool) (*models.Transaction, error) { transaction := new(models.Transaction) query := db.NewSelect(). Model(transaction). Where("id = ?", transactionID) if lock { query = query.For("UPDATE") } if err := query.Scan(ctx); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrTransactionNotFound } return nil, err } return transaction, nil } func (s *Service) getUserGameTx(ctx context.Context, db bun.IDB, userID, gameID int64, lock bool) (*models.UserGame, error) { userGame := new(models.UserGame) query := db.NewSelect(). Model(userGame). Where("user_id = ? AND game_id = ?", userID, gameID) if lock { query = query.For("UPDATE") } if err := query.Scan(ctx); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrAccountNotFound } return nil, err } return userGame, nil } func (s *Service) getBalancesTx(ctx context.Context, db bun.IDB, userID, gameID int64) (Balances, error) { userGame, err := s.getUserGameTx(ctx, db, userID, gameID, false) if err != nil { return Balances{}, err } reserved, err := s.sumReservedTx(ctx, db, userID, gameID) if err != nil { return Balances{}, err } return Balances{ Available: moneyToMinorUnits(userGame.Balance), Reserved: reserved, }, nil } func (s *Service) sumReservedTx(ctx context.Context, db bun.IDB, userID, gameID int64) (int64, error) { var reserved int64 if err := db.NewSelect(). Model((*models.Transaction)(nil)). ColumnExpr("COALESCE(SUM(amount), 0)"). Where("user_id = ? AND game_id = ? AND status = ?", userID, gameID, StatusReserved). Scan(ctx, &reserved); err != nil { return 0, err } return reserved, nil } func moneyToMinorUnits(amount float64) int64 { return int64(math.Round(amount * 100)) } func minorUnitsToMoney(amount int64) float64 { return float64(amount) / 100 }