332 lines
7.9 KiB
Go
332 lines
7.9 KiB
Go
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
|
|
}
|