package template import ( "context" "database/sql" "fmt" "time" ) // BuyinConfig represents a reusable buy-in configuration for tournaments. type BuyinConfig struct { ID int64 `json:"id"` Name string `json:"name"` BuyinAmount int64 `json:"buyin_amount"` StartingChips int64 `json:"starting_chips"` RakeTotal int64 `json:"rake_total"` BountyAmount int64 `json:"bounty_amount"` BountyChip int64 `json:"bounty_chip"` RebuyAllowed bool `json:"rebuy_allowed"` RebuyCost int64 `json:"rebuy_cost"` RebuyChips int64 `json:"rebuy_chips"` RebuyRake int64 `json:"rebuy_rake"` RebuyLimit int `json:"rebuy_limit"` RebuyLevelCutoff *int `json:"rebuy_level_cutoff,omitempty"` RebuyTimeCutoffSeconds *int `json:"rebuy_time_cutoff_seconds,omitempty"` RebuyChipThreshold *int64 `json:"rebuy_chip_threshold,omitempty"` AddonAllowed bool `json:"addon_allowed"` AddonCost int64 `json:"addon_cost"` AddonChips int64 `json:"addon_chips"` AddonRake int64 `json:"addon_rake"` AddonLevelStart *int `json:"addon_level_start,omitempty"` AddonLevelEnd *int `json:"addon_level_end,omitempty"` ReentryAllowed bool `json:"reentry_allowed"` ReentryLimit int `json:"reentry_limit"` LateRegLevelCutoff *int `json:"late_reg_level_cutoff,omitempty"` LateRegTimeCutoffSecs *int `json:"late_reg_time_cutoff_seconds,omitempty"` RakeSplits []RakeSplit `json:"rake_splits,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // RakeSplit defines how rake is distributed across categories. type RakeSplit struct { ID int64 `json:"id,omitempty"` BuyinConfigID int64 `json:"buyin_config_id,omitempty"` Category string `json:"category"` Amount int64 `json:"amount"` } // BuyinService provides CRUD operations for buy-in configs. type BuyinService struct { db *sql.DB } // NewBuyinService creates a new BuyinService. func NewBuyinService(db *sql.DB) *BuyinService { return &BuyinService{db: db} } // validateBuyinConfig checks that the config is valid. func validateBuyinConfig(cfg *BuyinConfig) error { if cfg.Name == "" { return fmt.Errorf("buy-in config name is required") } if cfg.BuyinAmount < 0 { return fmt.Errorf("buyin_amount must be non-negative") } if cfg.StartingChips < 0 { return fmt.Errorf("starting_chips must be non-negative") } if cfg.RakeTotal < 0 { return fmt.Errorf("rake_total must be non-negative") } if cfg.BountyAmount < 0 { return fmt.Errorf("bounty_amount must be non-negative") } if cfg.BountyAmount > 0 && cfg.BountyChip <= 0 { return fmt.Errorf("bounty_chip must be positive when bounty_amount > 0") } if cfg.RebuyCost < 0 || cfg.RebuyChips < 0 || cfg.RebuyRake < 0 { return fmt.Errorf("rebuy values must be non-negative") } if cfg.RebuyLimit < 0 { return fmt.Errorf("rebuy_limit must be non-negative") } if cfg.AddonCost < 0 || cfg.AddonChips < 0 || cfg.AddonRake < 0 { return fmt.Errorf("addon values must be non-negative") } if cfg.ReentryLimit < 0 { return fmt.Errorf("reentry_limit must be non-negative") } // Validate rake splits sum = rake_total if len(cfg.RakeSplits) > 0 { var splitSum int64 for _, split := range cfg.RakeSplits { if split.Amount < 0 { return fmt.Errorf("rake split amount must be non-negative") } validCategories := map[string]bool{"house": true, "staff": true, "league": true, "season_reserve": true} if !validCategories[split.Category] { return fmt.Errorf("invalid rake split category: %q", split.Category) } splitSum += split.Amount } if splitSum != cfg.RakeTotal { return fmt.Errorf("rake splits sum (%d) does not match rake_total (%d)", splitSum, cfg.RakeTotal) } } return nil } // CreateBuyinConfig creates a new buy-in configuration. func (s *BuyinService) CreateBuyinConfig(ctx context.Context, cfg *BuyinConfig) (*BuyinConfig, error) { if err := validateBuyinConfig(cfg); err != nil { return nil, err } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, fmt.Errorf("begin transaction: %w", err) } defer tx.Rollback() now := time.Now().Unix() res, err := tx.ExecContext(ctx, `INSERT INTO buyin_configs (name, buyin_amount, starting_chips, rake_total, bounty_amount, bounty_chip, rebuy_allowed, rebuy_cost, rebuy_chips, rebuy_rake, rebuy_limit, rebuy_level_cutoff, rebuy_time_cutoff_seconds, rebuy_chip_threshold, addon_allowed, addon_cost, addon_chips, addon_rake, addon_level_start, addon_level_end, reentry_allowed, reentry_limit, late_reg_level_cutoff, late_reg_time_cutoff_seconds, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, cfg.Name, cfg.BuyinAmount, cfg.StartingChips, cfg.RakeTotal, cfg.BountyAmount, cfg.BountyChip, boolToInt(cfg.RebuyAllowed), cfg.RebuyCost, cfg.RebuyChips, cfg.RebuyRake, cfg.RebuyLimit, cfg.RebuyLevelCutoff, cfg.RebuyTimeCutoffSeconds, cfg.RebuyChipThreshold, boolToInt(cfg.AddonAllowed), cfg.AddonCost, cfg.AddonChips, cfg.AddonRake, cfg.AddonLevelStart, cfg.AddonLevelEnd, boolToInt(cfg.ReentryAllowed), cfg.ReentryLimit, cfg.LateRegLevelCutoff, cfg.LateRegTimeCutoffSecs, now, now, ) if err != nil { return nil, fmt.Errorf("insert buyin config: %w", err) } id, err := res.LastInsertId() if err != nil { return nil, fmt.Errorf("get buyin config id: %w", err) } if err := insertRakeSplits(ctx, tx, id, cfg.RakeSplits); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit: %w", err) } return s.GetBuyinConfig(ctx, id) } func insertRakeSplits(ctx context.Context, tx *sql.Tx, configID int64, splits []RakeSplit) error { for i, split := range splits { _, err := tx.ExecContext(ctx, "INSERT INTO rake_splits (buyin_config_id, category, amount) VALUES (?, ?, ?)", configID, split.Category, split.Amount, ) if err != nil { return fmt.Errorf("insert rake split %d: %w", i, err) } } return nil } // GetBuyinConfig retrieves a buy-in config by ID, including rake splits. func (s *BuyinService) GetBuyinConfig(ctx context.Context, id int64) (*BuyinConfig, error) { cfg := &BuyinConfig{} var createdAt, updatedAt int64 var rebuyAllowed, addonAllowed, reentryAllowed int err := s.db.QueryRowContext(ctx, `SELECT id, name, buyin_amount, starting_chips, rake_total, bounty_amount, bounty_chip, rebuy_allowed, rebuy_cost, rebuy_chips, rebuy_rake, rebuy_limit, rebuy_level_cutoff, rebuy_time_cutoff_seconds, rebuy_chip_threshold, addon_allowed, addon_cost, addon_chips, addon_rake, addon_level_start, addon_level_end, reentry_allowed, reentry_limit, late_reg_level_cutoff, late_reg_time_cutoff_seconds, created_at, updated_at FROM buyin_configs WHERE id = ?`, id, ).Scan( &cfg.ID, &cfg.Name, &cfg.BuyinAmount, &cfg.StartingChips, &cfg.RakeTotal, &cfg.BountyAmount, &cfg.BountyChip, &rebuyAllowed, &cfg.RebuyCost, &cfg.RebuyChips, &cfg.RebuyRake, &cfg.RebuyLimit, &cfg.RebuyLevelCutoff, &cfg.RebuyTimeCutoffSeconds, &cfg.RebuyChipThreshold, &addonAllowed, &cfg.AddonCost, &cfg.AddonChips, &cfg.AddonRake, &cfg.AddonLevelStart, &cfg.AddonLevelEnd, &reentryAllowed, &cfg.ReentryLimit, &cfg.LateRegLevelCutoff, &cfg.LateRegTimeCutoffSecs, &createdAt, &updatedAt, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("buyin config not found: %d", id) } if err != nil { return nil, fmt.Errorf("get buyin config: %w", err) } cfg.RebuyAllowed = rebuyAllowed != 0 cfg.AddonAllowed = addonAllowed != 0 cfg.ReentryAllowed = reentryAllowed != 0 cfg.CreatedAt = time.Unix(createdAt, 0) cfg.UpdatedAt = time.Unix(updatedAt, 0) // Load rake splits rows, err := s.db.QueryContext(ctx, "SELECT id, buyin_config_id, category, amount FROM rake_splits WHERE buyin_config_id = ? ORDER BY category", id, ) if err != nil { return nil, fmt.Errorf("get rake splits: %w", err) } defer rows.Close() for rows.Next() { var split RakeSplit if err := rows.Scan(&split.ID, &split.BuyinConfigID, &split.Category, &split.Amount); err != nil { return nil, fmt.Errorf("scan rake split: %w", err) } cfg.RakeSplits = append(cfg.RakeSplits, split) } return cfg, rows.Err() } // ListBuyinConfigs returns all buy-in configs (without rake splits). func (s *BuyinService) ListBuyinConfigs(ctx context.Context) ([]BuyinConfig, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, name, buyin_amount, starting_chips, rake_total, bounty_amount, bounty_chip, rebuy_allowed, addon_allowed, reentry_allowed, created_at, updated_at FROM buyin_configs ORDER BY name`, ) if err != nil { return nil, fmt.Errorf("list buyin configs: %w", err) } defer rows.Close() var configs []BuyinConfig for rows.Next() { var cfg BuyinConfig var createdAt, updatedAt int64 var rebuyAllowed, addonAllowed, reentryAllowed int if err := rows.Scan( &cfg.ID, &cfg.Name, &cfg.BuyinAmount, &cfg.StartingChips, &cfg.RakeTotal, &cfg.BountyAmount, &cfg.BountyChip, &rebuyAllowed, &addonAllowed, &reentryAllowed, &createdAt, &updatedAt, ); err != nil { return nil, fmt.Errorf("scan buyin config: %w", err) } cfg.RebuyAllowed = rebuyAllowed != 0 cfg.AddonAllowed = addonAllowed != 0 cfg.ReentryAllowed = reentryAllowed != 0 cfg.CreatedAt = time.Unix(createdAt, 0) cfg.UpdatedAt = time.Unix(updatedAt, 0) configs = append(configs, cfg) } return configs, rows.Err() } // UpdateBuyinConfig updates a buy-in config and replaces its rake splits. func (s *BuyinService) UpdateBuyinConfig(ctx context.Context, id int64, cfg *BuyinConfig) error { cfg.ID = id if err := validateBuyinConfig(cfg); err != nil { return err } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin transaction: %w", err) } defer tx.Rollback() now := time.Now().Unix() res, err := tx.ExecContext(ctx, `UPDATE buyin_configs SET name = ?, buyin_amount = ?, starting_chips = ?, rake_total = ?, bounty_amount = ?, bounty_chip = ?, rebuy_allowed = ?, rebuy_cost = ?, rebuy_chips = ?, rebuy_rake = ?, rebuy_limit = ?, rebuy_level_cutoff = ?, rebuy_time_cutoff_seconds = ?, rebuy_chip_threshold = ?, addon_allowed = ?, addon_cost = ?, addon_chips = ?, addon_rake = ?, addon_level_start = ?, addon_level_end = ?, reentry_allowed = ?, reentry_limit = ?, late_reg_level_cutoff = ?, late_reg_time_cutoff_seconds = ?, updated_at = ? WHERE id = ?`, cfg.Name, cfg.BuyinAmount, cfg.StartingChips, cfg.RakeTotal, cfg.BountyAmount, cfg.BountyChip, boolToInt(cfg.RebuyAllowed), cfg.RebuyCost, cfg.RebuyChips, cfg.RebuyRake, cfg.RebuyLimit, cfg.RebuyLevelCutoff, cfg.RebuyTimeCutoffSeconds, cfg.RebuyChipThreshold, boolToInt(cfg.AddonAllowed), cfg.AddonCost, cfg.AddonChips, cfg.AddonRake, cfg.AddonLevelStart, cfg.AddonLevelEnd, boolToInt(cfg.ReentryAllowed), cfg.ReentryLimit, cfg.LateRegLevelCutoff, cfg.LateRegTimeCutoffSecs, now, id, ) if err != nil { return fmt.Errorf("update buyin config: %w", err) } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("buyin config not found: %d", id) } // Replace rake splits if _, err := tx.ExecContext(ctx, "DELETE FROM rake_splits WHERE buyin_config_id = ?", id); err != nil { return fmt.Errorf("delete old rake splits: %w", err) } if err := insertRakeSplits(ctx, tx, id, cfg.RakeSplits); err != nil { return err } return tx.Commit() } // DeleteBuyinConfig deletes a buy-in config. func (s *BuyinService) DeleteBuyinConfig(ctx context.Context, id int64) error { // Check if referenced by active tournaments var refCount int err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM tournaments WHERE buyin_config_id = ? AND status NOT IN ('completed', 'cancelled')", id, ).Scan(&refCount) if err != nil { return fmt.Errorf("check references: %w", err) } if refCount > 0 { return fmt.Errorf("buyin config is referenced by %d active tournament(s)", refCount) } res, err := s.db.ExecContext(ctx, "DELETE FROM buyin_configs WHERE id = ?", id) if err != nil { return fmt.Errorf("delete buyin config: %w", err) } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("buyin config not found: %d", id) } return nil } // DuplicateBuyinConfig creates an independent copy of a buy-in config. func (s *BuyinService) DuplicateBuyinConfig(ctx context.Context, id int64, newName string) (*BuyinConfig, error) { original, err := s.GetBuyinConfig(ctx, id) if err != nil { return nil, fmt.Errorf("get original: %w", err) } if newName == "" { newName = original.Name + " (Copy)" } copy := *original copy.ID = 0 copy.Name = newName return s.CreateBuyinConfig(ctx, ©) } // boolToInt converts a Go bool to SQLite integer. func boolToInt(b bool) int { if b { return 1 } return 0 }