cab-backend/internal/wallet/service.go
2026-03-30 21:00:35 +03:00

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
}