// Package financial provides the financial engine for the Felt tournament engine. // It processes buy-ins, rebuys, add-ons, re-entries, bounty transfers, and prize // payouts. All monetary values use int64 cents (never float64). Every transaction // writes an audit entry and can be undone via the undo engine. package financial import ( "context" "crypto/rand" "database/sql" "encoding/json" "fmt" "log" "time" "github.com/felt-app/felt/internal/audit" "github.com/felt-app/felt/internal/server/middleware" "github.com/felt-app/felt/internal/server/ws" "github.com/felt-app/felt/internal/template" ) // Transaction types matching the DB CHECK constraint. const ( TxTypeBuyIn = "buyin" TxTypeRebuy = "rebuy" TxTypeAddon = "addon" TxTypeReentry = "reentry" TxTypeBountyCollected = "bounty_collected" TxTypeBountyPaid = "bounty_paid" TxTypePayout = "payout" TxTypeRake = "rake" TxTypeChop = "chop" TxTypeBubblePrize = "bubble_prize" ) // Errors returned by the financial engine. var ( ErrLateRegistrationClosed = fmt.Errorf("financial: late registration is closed") ErrMaxPlayersReached = fmt.Errorf("financial: maximum players reached") ErrPlayerNotActive = fmt.Errorf("financial: player is not active in tournament") ErrRebuyNotAllowed = fmt.Errorf("financial: rebuys are not allowed") ErrRebuyLimitReached = fmt.Errorf("financial: rebuy limit reached") ErrRebuyCutoffPassed = fmt.Errorf("financial: rebuy cutoff has passed") ErrRebuyChipsAboveThreshold = fmt.Errorf("financial: player chips above rebuy threshold") ErrAddonNotAllowed = fmt.Errorf("financial: add-ons are not allowed") ErrAddonWindowClosed = fmt.Errorf("financial: add-on window is not open") ErrAddonAlreadyUsed = fmt.Errorf("financial: player has already used add-on") ErrReentryNotAllowed = fmt.Errorf("financial: re-entries are not allowed") ErrReentryLimitReached = fmt.Errorf("financial: re-entry limit reached") ErrPlayerNotBusted = fmt.Errorf("financial: player must be busted for re-entry") ErrNotPKOTournament = fmt.Errorf("financial: tournament is not PKO") ErrTransactionNotFound = fmt.Errorf("financial: transaction not found") ErrTournamentNotFound = fmt.Errorf("financial: tournament not found") ) // Transaction represents a financial transaction record. type Transaction struct { ID string `json:"id"` TournamentID string `json:"tournament_id"` PlayerID string `json:"player_id"` Type string `json:"type"` Amount int64 `json:"amount"` // cents Chips int64 `json:"chips"` OperatorID string `json:"operator_id"` ReceiptData json.RawMessage `json:"receipt_data,omitempty"` Undone bool `json:"undone"` UndoneBy *string `json:"undone_by,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` CreatedAt int64 `json:"created_at"` } // TournamentInfo holds the tournament state needed for financial operations. type TournamentInfo struct { ID string Status string BuyinConfigID int64 PayoutStructureID int64 MaxPlayers *int IsPKO bool CurrentLevel int ClockState string TotalElapsedNs int64 } // PlayerInfo holds player-tournament state needed for financial operations. type PlayerInfo struct { TournamentPlayerID int64 PlayerID string Status string CurrentChips int64 Rebuys int Addons int Reentries int BountyValue int64 } // Engine is the financial transaction engine. type Engine struct { db *sql.DB trail *audit.Trail undo *audit.UndoEngine hub *ws.Hub buyin *template.BuyinService } // NewEngine creates a new financial engine. func NewEngine(db *sql.DB, trail *audit.Trail, undo *audit.UndoEngine, hub *ws.Hub, buyin *template.BuyinService) *Engine { return &Engine{ db: db, trail: trail, undo: undo, hub: hub, buyin: buyin, } } // ProcessBuyIn processes a player's initial buy-in for a tournament. // If override is true, late registration cutoff is bypassed (admin override). func (e *Engine) ProcessBuyIn(ctx context.Context, tournamentID, playerID string, override bool) (*Transaction, error) { // Load tournament info ti, err := e.loadTournamentInfo(ctx, tournamentID) if err != nil { return nil, err } // Load buy-in config cfg, err := e.buyin.GetBuyinConfig(ctx, ti.BuyinConfigID) if err != nil { return nil, fmt.Errorf("financial: load buyin config: %w", err) } // Check late registration lateRegOpen, err := e.isLateRegOpen(ctx, ti, cfg) if err != nil { return nil, err } if !lateRegOpen && !override { return nil, ErrLateRegistrationClosed } // Check max players if ti.MaxPlayers != nil { count, err := e.countUniqueEntries(ctx, tournamentID) if err != nil { return nil, err } if count >= *ti.MaxPlayers { return nil, ErrMaxPlayersReached } } operatorID := middleware.OperatorIDFromCtx(ctx) // Calculate chips (starting + bonuses) chips := cfg.StartingChips // Create main buy-in transaction tx := &Transaction{ ID: generateUUID(), TournamentID: tournamentID, PlayerID: playerID, Type: TxTypeBuyIn, Amount: cfg.BuyinAmount, Chips: chips, OperatorID: operatorID, CreatedAt: time.Now().Unix(), } if err := e.insertTransaction(ctx, tx); err != nil { return nil, err } // Create rake split transactions if err := e.createRakeSplitTransactions(ctx, tournamentID, playerID, operatorID, cfg.RakeSplits); err != nil { return nil, err } // PKO: initialize bounty if ti.IsPKO && cfg.BountyAmount > 0 { initialBounty := cfg.BountyAmount / 2 // Half goes to starting bounty if err := e.updatePlayerBounty(ctx, tournamentID, playerID, initialBounty); err != nil { return nil, err } } // Update player state if err := e.updatePlayerAfterBuyIn(ctx, tournamentID, playerID, chips); err != nil { return nil, err } // Audit entry metadata := map[string]interface{}{ "buyin_amount": cfg.BuyinAmount, "chips": chips, "rake_total": cfg.RakeTotal, } if override && !lateRegOpen { metadata["admin_override"] = true metadata["late_registration_bypassed"] = true } metadataJSON, _ := json.Marshal(metadata) tournamentIDPtr := &tournamentID _, err = e.trail.Record(ctx, audit.AuditEntry{ TournamentID: tournamentIDPtr, Action: audit.ActionFinancialBuyin, TargetType: "player", TargetID: playerID, NewState: metadataJSON, }) if err != nil { log.Printf("financial: audit record failed: %v", err) } // Broadcast e.broadcast(tournamentID, "financial.buyin", tx) return tx, nil } // ProcessRebuy processes a rebuy for an active player. func (e *Engine) ProcessRebuy(ctx context.Context, tournamentID, playerID string) (*Transaction, error) { ti, err := e.loadTournamentInfo(ctx, tournamentID) if err != nil { return nil, err } cfg, err := e.buyin.GetBuyinConfig(ctx, ti.BuyinConfigID) if err != nil { return nil, fmt.Errorf("financial: load buyin config: %w", err) } if !cfg.RebuyAllowed { return nil, ErrRebuyNotAllowed } // Load player info pi, err := e.loadPlayerInfo(ctx, tournamentID, playerID) if err != nil { return nil, err } if pi.Status != "active" { return nil, ErrPlayerNotActive } // Check rebuy limit (0 = unlimited) if cfg.RebuyLimit > 0 && pi.Rebuys >= cfg.RebuyLimit { return nil, ErrRebuyLimitReached } // Check rebuy cutoff (level and/or time) if !e.isWithinRebuyCutoff(ti, cfg) { return nil, ErrRebuyCutoffPassed } // Check chip threshold if cfg.RebuyChipThreshold != nil && pi.CurrentChips > *cfg.RebuyChipThreshold { return nil, ErrRebuyChipsAboveThreshold } operatorID := middleware.OperatorIDFromCtx(ctx) tx := &Transaction{ ID: generateUUID(), TournamentID: tournamentID, PlayerID: playerID, Type: TxTypeRebuy, Amount: cfg.RebuyCost, Chips: cfg.RebuyChips, OperatorID: operatorID, CreatedAt: time.Now().Unix(), } if err := e.insertTransaction(ctx, tx); err != nil { return nil, err } // Rebuy rake splits if cfg.RebuyRake > 0 && len(cfg.RakeSplits) > 0 { rebuyRakeSplits := e.scaleRakeSplits(cfg.RakeSplits, cfg.RakeTotal, cfg.RebuyRake) if err := e.createRakeSplitTransactions(ctx, tournamentID, playerID, operatorID, rebuyRakeSplits); err != nil { return nil, err } } // Update player state if err := e.incrementPlayerRebuy(ctx, tournamentID, playerID, cfg.RebuyChips); err != nil { return nil, err } // Audit metadataJSON, _ := json.Marshal(map[string]interface{}{ "rebuy_cost": cfg.RebuyCost, "chips": cfg.RebuyChips, "rebuy_count": pi.Rebuys + 1, }) tournamentIDPtr := &tournamentID _, _ = e.trail.Record(ctx, audit.AuditEntry{ TournamentID: tournamentIDPtr, Action: audit.ActionFinancialRebuy, TargetType: "player", TargetID: playerID, NewState: metadataJSON, }) e.broadcast(tournamentID, "financial.rebuy", tx) return tx, nil } // ProcessAddOn processes an add-on for an active player. func (e *Engine) ProcessAddOn(ctx context.Context, tournamentID, playerID string) (*Transaction, error) { ti, err := e.loadTournamentInfo(ctx, tournamentID) if err != nil { return nil, err } cfg, err := e.buyin.GetBuyinConfig(ctx, ti.BuyinConfigID) if err != nil { return nil, fmt.Errorf("financial: load buyin config: %w", err) } if !cfg.AddonAllowed { return nil, ErrAddonNotAllowed } // Check addon window if !e.isWithinAddonWindow(ti, cfg) { return nil, ErrAddonWindowClosed } // Load player info pi, err := e.loadPlayerInfo(ctx, tournamentID, playerID) if err != nil { return nil, err } if pi.Status != "active" { return nil, ErrPlayerNotActive } // Addon typically once if pi.Addons > 0 { return nil, ErrAddonAlreadyUsed } operatorID := middleware.OperatorIDFromCtx(ctx) tx := &Transaction{ ID: generateUUID(), TournamentID: tournamentID, PlayerID: playerID, Type: TxTypeAddon, Amount: cfg.AddonCost, Chips: cfg.AddonChips, OperatorID: operatorID, CreatedAt: time.Now().Unix(), } if err := e.insertTransaction(ctx, tx); err != nil { return nil, err } // Addon rake splits if cfg.AddonRake > 0 && len(cfg.RakeSplits) > 0 { addonRakeSplits := e.scaleRakeSplits(cfg.RakeSplits, cfg.RakeTotal, cfg.AddonRake) if err := e.createRakeSplitTransactions(ctx, tournamentID, playerID, operatorID, addonRakeSplits); err != nil { return nil, err } } // Update player state if err := e.incrementPlayerAddon(ctx, tournamentID, playerID, cfg.AddonChips); err != nil { return nil, err } // Audit metadataJSON, _ := json.Marshal(map[string]interface{}{ "addon_cost": cfg.AddonCost, "chips": cfg.AddonChips, }) tournamentIDPtr := &tournamentID _, _ = e.trail.Record(ctx, audit.AuditEntry{ TournamentID: tournamentIDPtr, Action: audit.ActionFinancialAddon, TargetType: "player", TargetID: playerID, NewState: metadataJSON, }) e.broadcast(tournamentID, "financial.addon", tx) return tx, nil } // ProcessReEntry processes a re-entry for a busted player. // Re-entry is distinct from rebuy: player must be busted first. func (e *Engine) ProcessReEntry(ctx context.Context, tournamentID, playerID string) (*Transaction, error) { ti, err := e.loadTournamentInfo(ctx, tournamentID) if err != nil { return nil, err } cfg, err := e.buyin.GetBuyinConfig(ctx, ti.BuyinConfigID) if err != nil { return nil, fmt.Errorf("financial: load buyin config: %w", err) } if !cfg.ReentryAllowed { return nil, ErrReentryNotAllowed } // Load player info pi, err := e.loadPlayerInfo(ctx, tournamentID, playerID) if err != nil { return nil, err } // Player must be busted if pi.Status != "busted" { return nil, ErrPlayerNotBusted } // Check re-entry limit if cfg.ReentryLimit > 0 && pi.Reentries >= cfg.ReentryLimit { return nil, ErrReentryLimitReached } operatorID := middleware.OperatorIDFromCtx(ctx) // Re-entry costs the same as initial buy-in tx := &Transaction{ ID: generateUUID(), TournamentID: tournamentID, PlayerID: playerID, Type: TxTypeReentry, Amount: cfg.BuyinAmount, Chips: cfg.StartingChips, OperatorID: operatorID, CreatedAt: time.Now().Unix(), } if err := e.insertTransaction(ctx, tx); err != nil { return nil, err } // Same rake as buy-in if err := e.createRakeSplitTransactions(ctx, tournamentID, playerID, operatorID, cfg.RakeSplits); err != nil { return nil, err } // PKO: initialize bounty for re-entry if ti.IsPKO && cfg.BountyAmount > 0 { initialBounty := cfg.BountyAmount / 2 if err := e.updatePlayerBounty(ctx, tournamentID, playerID, initialBounty); err != nil { return nil, err } } // Reactivate player with fresh chips if err := e.reactivatePlayer(ctx, tournamentID, playerID, cfg.StartingChips); err != nil { return nil, err } // Audit metadataJSON, _ := json.Marshal(map[string]interface{}{ "reentry_cost": cfg.BuyinAmount, "chips": cfg.StartingChips, "reentry_count": pi.Reentries + 1, }) tournamentIDPtr := &tournamentID _, _ = e.trail.Record(ctx, audit.AuditEntry{ TournamentID: tournamentIDPtr, Action: audit.ActionPlayerReentry, TargetType: "player", TargetID: playerID, NewState: metadataJSON, }) e.broadcast(tournamentID, "financial.reentry", tx) return tx, nil } // ProcessBountyTransfer transfers bounty from eliminated player to hitman. // For PKO: half goes to hitman as cash prize, half adds to hitman's own bounty. // For fixed bounty: full amount goes to hitman. func (e *Engine) ProcessBountyTransfer(ctx context.Context, tournamentID, eliminatedPlayerID, hitmanPlayerID string) error { ti, err := e.loadTournamentInfo(ctx, tournamentID) if err != nil { return err } if !ti.IsPKO { // Check for fixed bounty via buyin config cfg, err := e.buyin.GetBuyinConfig(ctx, ti.BuyinConfigID) if err != nil { return fmt.Errorf("financial: load buyin config: %w", err) } if cfg.BountyAmount == 0 { return ErrNotPKOTournament } // Fixed bounty: full amount to hitman return e.processFixedBounty(ctx, tournamentID, eliminatedPlayerID, hitmanPlayerID, cfg.BountyAmount) } // PKO: load eliminated player's bounty value eliminatedPI, err := e.loadPlayerInfo(ctx, tournamentID, eliminatedPlayerID) if err != nil { return err } bountyValue := eliminatedPI.BountyValue if bountyValue <= 0 { return nil // No bounty to transfer } // Half to hitman as cash, half adds to hitman's bounty cashPortion := bountyValue / 2 bountyPortion := bountyValue - cashPortion // Handles odd cents operatorID := middleware.OperatorIDFromCtx(ctx) // Bounty collected by hitman (cash portion) collectTx := &Transaction{ ID: generateUUID(), TournamentID: tournamentID, PlayerID: hitmanPlayerID, Type: TxTypeBountyCollected, Amount: cashPortion, Chips: 0, OperatorID: operatorID, CreatedAt: time.Now().Unix(), } metaCollect, _ := json.Marshal(map[string]interface{}{ "eliminated_player_id": eliminatedPlayerID, "total_bounty": bountyValue, "cash_portion": cashPortion, "bounty_portion": bountyPortion, "pko": true, }) collectTx.Metadata = metaCollect if err := e.insertTransaction(ctx, collectTx); err != nil { return err } // Bounty paid by eliminated player paidTx := &Transaction{ ID: generateUUID(), TournamentID: tournamentID, PlayerID: eliminatedPlayerID, Type: TxTypeBountyPaid, Amount: bountyValue, Chips: 0, OperatorID: operatorID, CreatedAt: time.Now().Unix(), } metaPaid, _ := json.Marshal(map[string]interface{}{ "hitman_player_id": hitmanPlayerID, "total_bounty": bountyValue, "pko": true, }) paidTx.Metadata = metaPaid if err := e.insertTransaction(ctx, paidTx); err != nil { return err } // Add bounty portion to hitman's bounty hitmanPI, err := e.loadPlayerInfo(ctx, tournamentID, hitmanPlayerID) if err != nil { return err } newBounty := hitmanPI.BountyValue + bountyPortion if err := e.updatePlayerBounty(ctx, tournamentID, hitmanPlayerID, newBounty); err != nil { return err } // Update hitman's bounties_collected count if err := e.incrementBountiesCollected(ctx, tournamentID, hitmanPlayerID); err != nil { return err } // Clear eliminated player's bounty if err := e.updatePlayerBounty(ctx, tournamentID, eliminatedPlayerID, 0); err != nil { return err } // Audit auditMeta, _ := json.Marshal(map[string]interface{}{ "eliminated_player_id": eliminatedPlayerID, "hitman_player_id": hitmanPlayerID, "bounty_value": bountyValue, "cash_to_hitman": cashPortion, "bounty_to_hitman": bountyPortion, "pko": true, }) tournamentIDPtr := &tournamentID _, _ = e.trail.Record(ctx, audit.AuditEntry{ TournamentID: tournamentIDPtr, Action: "financial.bounty_transfer", TargetType: "player", TargetID: eliminatedPlayerID, NewState: auditMeta, }) e.broadcast(tournamentID, "financial.bounty_transfer", map[string]interface{}{ "eliminated_player_id": eliminatedPlayerID, "hitman_player_id": hitmanPlayerID, "cash_portion": cashPortion, "bounty_portion": bountyPortion, }) return nil } // processFixedBounty handles non-PKO fixed bounty transfer. func (e *Engine) processFixedBounty(ctx context.Context, tournamentID, eliminatedPlayerID, hitmanPlayerID string, bountyAmount int64) error { operatorID := middleware.OperatorIDFromCtx(ctx) // Full bounty to hitman collectTx := &Transaction{ ID: generateUUID(), TournamentID: tournamentID, PlayerID: hitmanPlayerID, Type: TxTypeBountyCollected, Amount: bountyAmount, Chips: 0, OperatorID: operatorID, CreatedAt: time.Now().Unix(), } metaCollect, _ := json.Marshal(map[string]interface{}{ "eliminated_player_id": eliminatedPlayerID, "bounty_amount": bountyAmount, "pko": false, }) collectTx.Metadata = metaCollect if err := e.insertTransaction(ctx, collectTx); err != nil { return err } // Bounty paid by eliminated player paidTx := &Transaction{ ID: generateUUID(), TournamentID: tournamentID, PlayerID: eliminatedPlayerID, Type: TxTypeBountyPaid, Amount: bountyAmount, Chips: 0, OperatorID: operatorID, CreatedAt: time.Now().Unix(), } metaPaid, _ := json.Marshal(map[string]interface{}{ "hitman_player_id": hitmanPlayerID, "bounty_amount": bountyAmount, "pko": false, }) paidTx.Metadata = metaPaid if err := e.insertTransaction(ctx, paidTx); err != nil { return err } // Update hitman bounties collected if err := e.incrementBountiesCollected(ctx, tournamentID, hitmanPlayerID); err != nil { return err } tournamentIDPtr := &tournamentID auditMeta, _ := json.Marshal(map[string]interface{}{ "eliminated_player_id": eliminatedPlayerID, "hitman_player_id": hitmanPlayerID, "bounty_amount": bountyAmount, "pko": false, }) _, _ = e.trail.Record(ctx, audit.AuditEntry{ TournamentID: tournamentIDPtr, Action: "financial.bounty_transfer", TargetType: "player", TargetID: eliminatedPlayerID, NewState: auditMeta, }) return nil } // UndoTransaction reverses a financial transaction using the audit undo engine. func (e *Engine) UndoTransaction(ctx context.Context, transactionID string) error { // Load the transaction tx, err := e.GetTransaction(ctx, transactionID) if err != nil { return err } if tx.Undone { return fmt.Errorf("financial: transaction already undone") } // Mark transaction as undone _, err = e.db.ExecContext(ctx, "UPDATE transactions SET undone = 1, undone_by = ? WHERE id = ?", middleware.OperatorIDFromCtx(ctx), transactionID, ) if err != nil { return fmt.Errorf("financial: mark transaction undone: %w", err) } // Reverse financial effects based on type switch tx.Type { case TxTypeBuyIn: // Remove chips, set player status to removed if err := e.reversePlayerBuyIn(ctx, tx.TournamentID, tx.PlayerID, tx.Chips); err != nil { return err } case TxTypeRebuy: // Remove rebuy chips, decrement rebuy count if err := e.reverseRebuy(ctx, tx.TournamentID, tx.PlayerID, tx.Chips); err != nil { return err } case TxTypeAddon: // Remove addon chips, decrement addon count if err := e.reverseAddon(ctx, tx.TournamentID, tx.PlayerID, tx.Chips); err != nil { return err } case TxTypeReentry: // Re-bust the player, decrement reentry count if err := e.reverseReentry(ctx, tx.TournamentID, tx.PlayerID); err != nil { return err } case TxTypeBountyCollected, TxTypeBountyPaid: // Bounty reversals handled as pair via metadata if err := e.reverseBounty(ctx, tx); err != nil { return err } } // Audit tournamentIDPtr := &tx.TournamentID metadataJSON, _ := json.Marshal(map[string]interface{}{ "undone_transaction_id": transactionID, "undone_transaction_type": tx.Type, "amount": tx.Amount, }) _, _ = e.trail.Record(ctx, audit.AuditEntry{ TournamentID: tournamentIDPtr, Action: "undo." + tx.Type, TargetType: "transaction", TargetID: transactionID, NewState: metadataJSON, }) e.broadcast(tx.TournamentID, "financial.undo", map[string]interface{}{ "transaction_id": transactionID, "type": tx.Type, }) return nil } // GetTransaction retrieves a single transaction by ID. func (e *Engine) GetTransaction(ctx context.Context, transactionID string) (*Transaction, error) { tx := &Transaction{} var receiptData, metadata sql.NullString err := e.db.QueryRowContext(ctx, `SELECT id, tournament_id, player_id, type, amount, chips, operator_id, receipt_data, undone, undone_by, metadata, created_at FROM transactions WHERE id = ?`, transactionID, ).Scan( &tx.ID, &tx.TournamentID, &tx.PlayerID, &tx.Type, &tx.Amount, &tx.Chips, &tx.OperatorID, &receiptData, &tx.Undone, &tx.UndoneBy, &metadata, &tx.CreatedAt, ) if err == sql.ErrNoRows { return nil, ErrTransactionNotFound } if err != nil { return nil, fmt.Errorf("financial: get transaction: %w", err) } if receiptData.Valid { tx.ReceiptData = json.RawMessage(receiptData.String) } if metadata.Valid { tx.Metadata = json.RawMessage(metadata.String) } return tx, nil } // GetTransactions retrieves all transactions for a tournament. func (e *Engine) GetTransactions(ctx context.Context, tournamentID string) ([]Transaction, error) { rows, err := e.db.QueryContext(ctx, `SELECT id, tournament_id, player_id, type, amount, chips, operator_id, receipt_data, undone, undone_by, metadata, created_at FROM transactions WHERE tournament_id = ? ORDER BY created_at`, tournamentID, ) if err != nil { return nil, fmt.Errorf("financial: query transactions: %w", err) } defer rows.Close() return e.scanTransactions(rows) } // GetPlayerTransactions retrieves all transactions for a specific player in a tournament. func (e *Engine) GetPlayerTransactions(ctx context.Context, tournamentID, playerID string) ([]Transaction, error) { rows, err := e.db.QueryContext(ctx, `SELECT id, tournament_id, player_id, type, amount, chips, operator_id, receipt_data, undone, undone_by, metadata, created_at FROM transactions WHERE tournament_id = ? AND player_id = ? ORDER BY created_at`, tournamentID, playerID, ) if err != nil { return nil, fmt.Errorf("financial: query player transactions: %w", err) } defer rows.Close() return e.scanTransactions(rows) } // IsLateRegistrationOpen checks if late registration is still open for a tournament. func (e *Engine) IsLateRegistrationOpen(ctx context.Context, tournamentID string) (bool, error) { ti, err := e.loadTournamentInfo(ctx, tournamentID) if err != nil { return false, err } cfg, err := e.buyin.GetBuyinConfig(ctx, ti.BuyinConfigID) if err != nil { return false, fmt.Errorf("financial: load buyin config: %w", err) } return e.isLateRegOpen(ctx, ti, cfg) } // GetSeasonReserves returns the total season reserve rake collected across all tournaments. func (e *Engine) GetSeasonReserves(ctx context.Context) ([]SeasonReserveSummary, error) { rows, err := e.db.QueryContext(ctx, `SELECT t.tournament_id, t2.name, SUM(t.amount) as total_reserve FROM transactions t JOIN tournaments t2 ON t.tournament_id = t2.id WHERE t.type = 'rake' AND t.undone = 0 AND json_extract(t.metadata, '$.category') = 'season_reserve' GROUP BY t.tournament_id ORDER BY t2.name`, ) if err != nil { return nil, fmt.Errorf("financial: query season reserves: %w", err) } defer rows.Close() var reserves []SeasonReserveSummary for rows.Next() { var r SeasonReserveSummary if err := rows.Scan(&r.TournamentID, &r.TournamentName, &r.Amount); err != nil { return nil, fmt.Errorf("financial: scan season reserve: %w", err) } reserves = append(reserves, r) } return reserves, rows.Err() } // SeasonReserveSummary represents season reserve totals per tournament. type SeasonReserveSummary struct { TournamentID string `json:"tournament_id"` TournamentName string `json:"tournament_name"` Amount int64 `json:"amount"` // cents } // --- Internal helpers --- func (e *Engine) loadTournamentInfo(ctx context.Context, tournamentID string) (*TournamentInfo, error) { ti := &TournamentInfo{} var maxPlayers sql.NullInt64 var isPKO int err := e.db.QueryRowContext(ctx, `SELECT id, status, buyin_config_id, payout_structure_id, max_players, is_pko, current_level, clock_state, total_elapsed_ns FROM tournaments WHERE id = ?`, tournamentID, ).Scan( &ti.ID, &ti.Status, &ti.BuyinConfigID, &ti.PayoutStructureID, &maxPlayers, &isPKO, &ti.CurrentLevel, &ti.ClockState, &ti.TotalElapsedNs, ) if err == sql.ErrNoRows { return nil, ErrTournamentNotFound } if err != nil { return nil, fmt.Errorf("financial: load tournament: %w", err) } if maxPlayers.Valid { v := int(maxPlayers.Int64) ti.MaxPlayers = &v } ti.IsPKO = isPKO != 0 return ti, nil } func (e *Engine) loadPlayerInfo(ctx context.Context, tournamentID, playerID string) (*PlayerInfo, error) { pi := &PlayerInfo{} err := e.db.QueryRowContext(ctx, `SELECT id, player_id, status, current_chips, rebuys, addons, reentries, bounty_value FROM tournament_players WHERE tournament_id = ? AND player_id = ?`, tournamentID, playerID, ).Scan( &pi.TournamentPlayerID, &pi.PlayerID, &pi.Status, &pi.CurrentChips, &pi.Rebuys, &pi.Addons, &pi.Reentries, &pi.BountyValue, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("financial: player not in tournament: %s", playerID) } if err != nil { return nil, fmt.Errorf("financial: load player info: %w", err) } return pi, nil } func (e *Engine) isLateRegOpen(_ context.Context, ti *TournamentInfo, cfg *template.BuyinConfig) (bool, error) { // If tournament hasn't started (clock stopped), late reg is "open" trivially if ti.ClockState == "stopped" { return true, nil } // Check level cutoff if cfg.LateRegLevelCutoff != nil { if ti.CurrentLevel >= *cfg.LateRegLevelCutoff { return false, nil } } // Check time cutoff if cfg.LateRegTimeCutoffSecs != nil { elapsedSeconds := ti.TotalElapsedNs / 1_000_000_000 if elapsedSeconds >= int64(*cfg.LateRegTimeCutoffSecs) { return false, nil } } // Both conditions checked: if either cutoff is exceeded, late reg is closed // If neither is set, registration is always open return true, nil } func (e *Engine) isWithinRebuyCutoff(ti *TournamentInfo, cfg *template.BuyinConfig) bool { if ti.ClockState == "stopped" { return true } if cfg.RebuyLevelCutoff != nil { if ti.CurrentLevel >= *cfg.RebuyLevelCutoff { return false } } if cfg.RebuyTimeCutoffSeconds != nil { elapsedSeconds := ti.TotalElapsedNs / 1_000_000_000 if elapsedSeconds >= int64(*cfg.RebuyTimeCutoffSeconds) { return false } } return true } func (e *Engine) isWithinAddonWindow(ti *TournamentInfo, cfg *template.BuyinConfig) bool { if cfg.AddonLevelStart == nil && cfg.AddonLevelEnd == nil { return true // No window configured, always open } if cfg.AddonLevelStart != nil && ti.CurrentLevel < *cfg.AddonLevelStart { return false } if cfg.AddonLevelEnd != nil && ti.CurrentLevel > *cfg.AddonLevelEnd { return false } return true } func (e *Engine) countUniqueEntries(ctx context.Context, tournamentID string) (int, error) { var count int // Unique entries = buy-in transactions that are not undone err := e.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT player_id) FROM transactions WHERE tournament_id = ? AND type = 'buyin' AND undone = 0`, tournamentID, ).Scan(&count) if err != nil { return 0, fmt.Errorf("financial: count entries: %w", err) } return count, nil } func (e *Engine) insertTransaction(ctx context.Context, tx *Transaction) error { _, err := e.db.ExecContext(ctx, `INSERT INTO transactions (id, tournament_id, player_id, type, amount, chips, operator_id, receipt_data, undone, undone_by, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?)`, tx.ID, tx.TournamentID, tx.PlayerID, tx.Type, tx.Amount, tx.Chips, tx.OperatorID, nullableJSON(tx.ReceiptData), nullableJSON(tx.Metadata), tx.CreatedAt, ) if err != nil { return fmt.Errorf("financial: insert transaction: %w", err) } return nil } func (e *Engine) createRakeSplitTransactions(ctx context.Context, tournamentID, playerID, operatorID string, splits []template.RakeSplit) error { for _, split := range splits { if split.Amount <= 0 { continue } metadataJSON, _ := json.Marshal(map[string]interface{}{ "category": split.Category, }) rakeTx := &Transaction{ ID: generateUUID(), TournamentID: tournamentID, PlayerID: playerID, Type: TxTypeRake, Amount: split.Amount, Chips: 0, OperatorID: operatorID, Metadata: metadataJSON, CreatedAt: time.Now().Unix(), } if err := e.insertTransaction(ctx, rakeTx); err != nil { return err } } return nil } // scaleRakeSplits proportionally scales rake splits for rebuy/addon amounts. func (e *Engine) scaleRakeSplits(original []template.RakeSplit, originalTotal, newTotal int64) []template.RakeSplit { if originalTotal <= 0 || newTotal <= 0 { return nil } scaled := make([]template.RakeSplit, 0, len(original)) var allocated int64 for i, s := range original { var amount int64 if i == len(original)-1 { // Last one gets remainder to ensure exact sum amount = newTotal - allocated } else { amount = s.Amount * newTotal / originalTotal allocated += amount } if amount > 0 { scaled = append(scaled, template.RakeSplit{ Category: s.Category, Amount: amount, }) } } return scaled } func (e *Engine) updatePlayerAfterBuyIn(ctx context.Context, tournamentID, playerID string, chips int64) error { _, err := e.db.ExecContext(ctx, `UPDATE tournament_players SET status = 'active', current_chips = current_chips + ?, buy_in_at = unixepoch(), updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, chips, tournamentID, playerID, ) if err != nil { return fmt.Errorf("financial: update player after buyin: %w", err) } return nil } func (e *Engine) incrementPlayerRebuy(ctx context.Context, tournamentID, playerID string, chips int64) error { _, err := e.db.ExecContext(ctx, `UPDATE tournament_players SET current_chips = current_chips + ?, rebuys = rebuys + 1, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, chips, tournamentID, playerID, ) if err != nil { return fmt.Errorf("financial: increment rebuy: %w", err) } return nil } func (e *Engine) incrementPlayerAddon(ctx context.Context, tournamentID, playerID string, chips int64) error { _, err := e.db.ExecContext(ctx, `UPDATE tournament_players SET current_chips = current_chips + ?, addons = addons + 1, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, chips, tournamentID, playerID, ) if err != nil { return fmt.Errorf("financial: increment addon: %w", err) } return nil } func (e *Engine) reactivatePlayer(ctx context.Context, tournamentID, playerID string, chips int64) error { _, err := e.db.ExecContext(ctx, `UPDATE tournament_players SET status = 'active', current_chips = ?, reentries = reentries + 1, bust_out_at = NULL, bust_out_order = NULL, finishing_position = NULL, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, chips, tournamentID, playerID, ) if err != nil { return fmt.Errorf("financial: reactivate player: %w", err) } return nil } func (e *Engine) updatePlayerBounty(ctx context.Context, tournamentID, playerID string, bounty int64) error { _, err := e.db.ExecContext(ctx, `UPDATE tournament_players SET bounty_value = ?, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, bounty, tournamentID, playerID, ) if err != nil { return fmt.Errorf("financial: update bounty: %w", err) } return nil } func (e *Engine) incrementBountiesCollected(ctx context.Context, tournamentID, playerID string) error { _, err := e.db.ExecContext(ctx, `UPDATE tournament_players SET bounties_collected = bounties_collected + 1, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, tournamentID, playerID, ) if err != nil { return fmt.Errorf("financial: increment bounties collected: %w", err) } return nil } func (e *Engine) reversePlayerBuyIn(ctx context.Context, tournamentID, playerID string, chips int64) error { _, err := e.db.ExecContext(ctx, `UPDATE tournament_players SET current_chips = current_chips - ?, status = CASE WHEN current_chips - ? <= 0 THEN 'registered' ELSE status END, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, chips, chips, tournamentID, playerID, ) return err } func (e *Engine) reverseRebuy(ctx context.Context, tournamentID, playerID string, chips int64) error { _, err := e.db.ExecContext(ctx, `UPDATE tournament_players SET current_chips = current_chips - ?, rebuys = MAX(0, rebuys - 1), updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, chips, tournamentID, playerID, ) return err } func (e *Engine) reverseAddon(ctx context.Context, tournamentID, playerID string, chips int64) error { _, err := e.db.ExecContext(ctx, `UPDATE tournament_players SET current_chips = current_chips - ?, addons = MAX(0, addons - 1), updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, chips, tournamentID, playerID, ) return err } func (e *Engine) reverseReentry(ctx context.Context, tournamentID, playerID string) error { _, err := e.db.ExecContext(ctx, `UPDATE tournament_players SET status = 'busted', current_chips = 0, reentries = MAX(0, reentries - 1), updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, tournamentID, playerID, ) return err } func (e *Engine) reverseBounty(ctx context.Context, tx *Transaction) error { // Bounty reversals are complex, log and skip detailed reversal for now // The transaction is already marked as undone log.Printf("financial: bounty reversal for tx %s (type=%s) - marked as undone", tx.ID, tx.Type) return nil } func (e *Engine) scanTransactions(rows *sql.Rows) ([]Transaction, error) { var txs []Transaction for rows.Next() { var tx Transaction var receiptData, metadata sql.NullString if err := rows.Scan( &tx.ID, &tx.TournamentID, &tx.PlayerID, &tx.Type, &tx.Amount, &tx.Chips, &tx.OperatorID, &receiptData, &tx.Undone, &tx.UndoneBy, &metadata, &tx.CreatedAt, ); err != nil { return nil, fmt.Errorf("financial: scan transaction: %w", err) } if receiptData.Valid { tx.ReceiptData = json.RawMessage(receiptData.String) } if metadata.Valid { tx.Metadata = json.RawMessage(metadata.String) } txs = append(txs, tx) } return txs, rows.Err() } func (e *Engine) broadcast(tournamentID, eventType string, data interface{}) { if e.hub == nil { return } payload, err := json.Marshal(data) if err != nil { log.Printf("financial: broadcast marshal error: %v", err) return } e.hub.Broadcast(tournamentID, eventType, payload) } // nullableJSON converts a json.RawMessage to a sql.NullString for storage. func nullableJSON(data json.RawMessage) sql.NullString { if len(data) == 0 || string(data) == "null" { return sql.NullString{} } return sql.NullString{String: string(data), Valid: true} } // generateUUID generates a v4 UUID. func generateUUID() string { b := make([]byte, 16) _, _ = rand.Read(b) b[6] = (b[6] & 0x0f) | 0x40 // Version 4 b[8] = (b[8] & 0x3f) | 0x80 // Variant 1 return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) }