package financial import ( "context" "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/template" ) // PrizePoolSummary contains the full prize pool breakdown for a tournament. type PrizePoolSummary struct { TotalEntries int `json:"total_entries"` // Unique entries only TotalRebuys int `json:"total_rebuys"` TotalAddOns int `json:"total_addons"` TotalReEntries int `json:"total_reentries"` TotalBuyInAmount int64 `json:"total_buyin_amount"` // cents TotalRebuyAmount int64 `json:"total_rebuy_amount"` // cents TotalAddOnAmount int64 `json:"total_addon_amount"` // cents TotalReEntryAmount int64 `json:"total_reentry_amount"` // cents TotalRake int64 `json:"total_rake"` // All rake combined RakeByCategory map[string]int64 `json:"rake_by_category"` // house, staff, league, season_reserve PrizePool int64 `json:"prize_pool"` // After rake, before guarantee Guarantee int64 `json:"guarantee"` // Configured guarantee HouseContribution int64 `json:"house_contribution"` // If pool < guarantee FinalPrizePool int64 `json:"final_prize_pool"` // Max(PrizePool, Guarantee) TotalBountyPool int64 `json:"total_bounty_pool"` // PKO bounties TotalChipsInPlay int64 `json:"total_chips_in_play"` // CHIP-03 AverageStack int64 `json:"average_stack"` // CHIP-04 RemainingPlayers int `json:"remaining_players"` } // Payout represents a single position's payout. type Payout struct { Position int `json:"position"` Amount int64 `json:"amount"` // cents Percentage int64 `json:"percentage"` // basis points (e.g., 5000 = 50.00%) } // BubblePrizeProposal represents a proposed bubble prize redistribution. type BubblePrizeProposal struct { ID int64 `json:"id"` TournamentID string `json:"tournament_id"` Amount int64 `json:"amount"` // cents OriginalPayouts []Payout `json:"original_payouts"` AdjustedPayouts []Payout `json:"adjusted_payouts"` FundedFrom json.RawMessage `json:"funded_from"` // JSON array of {position, reduction_amount} Status string `json:"status"` } // FundingSource documents how much was shaved from each position. type FundingSource struct { Position int `json:"position"` ReductionAmount int64 `json:"reduction_amount"` // cents } // CalculatePrizePool computes the full prize pool breakdown for a tournament. func (e *Engine) CalculatePrizePool(ctx context.Context, tournamentID string) (*PrizePoolSummary, error) { summary := &PrizePoolSummary{ RakeByCategory: make(map[string]int64), } // Count unique entries (distinct players with non-undone buyins) err := e.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT player_id) FROM transactions WHERE tournament_id = ? AND type = 'buyin' AND undone = 0`, tournamentID, ).Scan(&summary.TotalEntries) if err != nil { return nil, fmt.Errorf("financial: count entries: %w", err) } // Sum amounts by type (non-undone only) rows, err := e.db.QueryContext(ctx, `SELECT type, COUNT(*), COALESCE(SUM(amount), 0) FROM transactions WHERE tournament_id = ? AND undone = 0 AND type IN ('buyin', 'rebuy', 'addon', 'reentry') GROUP BY type`, tournamentID, ) if err != nil { return nil, fmt.Errorf("financial: sum amounts: %w", err) } defer rows.Close() for rows.Next() { var txType string var count int var amount int64 if err := rows.Scan(&txType, &count, &amount); err != nil { return nil, fmt.Errorf("financial: scan amount: %w", err) } switch txType { case TxTypeBuyIn: summary.TotalBuyInAmount = amount case TxTypeRebuy: summary.TotalRebuys = count summary.TotalRebuyAmount = amount case TxTypeAddon: summary.TotalAddOns = count summary.TotalAddOnAmount = amount case TxTypeReentry: summary.TotalReEntries = count summary.TotalReEntryAmount = amount } } if err := rows.Err(); err != nil { return nil, err } // Sum rake by category rakeRows, err := e.db.QueryContext(ctx, `SELECT COALESCE(json_extract(metadata, '$.category'), 'unknown'), COALESCE(SUM(amount), 0) FROM transactions WHERE tournament_id = ? AND type = 'rake' AND undone = 0 GROUP BY json_extract(metadata, '$.category')`, tournamentID, ) if err != nil { return nil, fmt.Errorf("financial: sum rake: %w", err) } defer rakeRows.Close() for rakeRows.Next() { var category string var amount int64 if err := rakeRows.Scan(&category, &amount); err != nil { return nil, fmt.Errorf("financial: scan rake: %w", err) } summary.RakeByCategory[category] = amount summary.TotalRake += amount } if err := rakeRows.Err(); err != nil { return nil, err } // Prize pool = total contributions minus rake totalContributions := summary.TotalBuyInAmount + summary.TotalRebuyAmount + summary.TotalAddOnAmount + summary.TotalReEntryAmount summary.PrizePool = totalContributions - summary.TotalRake // Load guarantee from tournament (stored in buyin config or tournament) // For now, guarantee comes from the payout structure or a tournament setting summary.Guarantee = 0 // TODO: load from tournament config when guarantee field is added if summary.PrizePool < summary.Guarantee { summary.HouseContribution = summary.Guarantee - summary.PrizePool } summary.FinalPrizePool = summary.PrizePool if summary.Guarantee > 0 && summary.PrizePool < summary.Guarantee { summary.FinalPrizePool = summary.Guarantee } // Bounty pool for PKO err = e.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(bounty_value), 0) FROM tournament_players WHERE tournament_id = ? AND status = 'active'`, tournamentID, ).Scan(&summary.TotalBountyPool) if err != nil { return nil, fmt.Errorf("financial: sum bounties: %w", err) } // Chips in play and remaining players err = e.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(current_chips), 0), COUNT(*) FROM tournament_players WHERE tournament_id = ? AND status IN ('active', 'deal')`, tournamentID, ).Scan(&summary.TotalChipsInPlay, &summary.RemainingPlayers) if err != nil { return nil, fmt.Errorf("financial: chip stats: %w", err) } if summary.RemainingPlayers > 0 { summary.AverageStack = summary.TotalChipsInPlay / int64(summary.RemainingPlayers) } return summary, nil } // CalculatePayouts computes the payout table for a tournament based on its // entry count and payout structure. All math uses int64 cents. Rounding is // always DOWN to the venue's configured denomination. Remainder goes to 1st. func (e *Engine) CalculatePayouts(ctx context.Context, tournamentID string) ([]Payout, error) { // Load prize pool summary, err := e.CalculatePrizePool(ctx, tournamentID) if err != nil { return nil, err } // Load tournament's payout structure ID ti, err := e.loadTournamentInfo(ctx, tournamentID) if err != nil { return nil, err } // Load payout structure payoutSvc := template.NewPayoutService(e.db) ps, err := payoutSvc.GetPayoutStructure(ctx, ti.PayoutStructureID) if err != nil { return nil, fmt.Errorf("financial: load payout structure: %w", err) } // Load venue rounding denomination roundingDenom := e.loadRoundingDenomination(ctx) // Select bracket by unique entry count bracket := selectBracket(ps.Brackets, summary.TotalEntries) if bracket == nil { return nil, fmt.Errorf("financial: no payout bracket for %d entries", summary.TotalEntries) } return CalculatePayoutsFromPool(summary.FinalPrizePool, bracket.Tiers, roundingDenom) } // CalculatePayoutsFromPool is the pure calculation function. It takes a prize pool, // payout tiers, and rounding denomination, and returns the payout table. // CRITICAL: sum(payouts) must ALWAYS equal prizePool. func CalculatePayoutsFromPool(prizePool int64, tiers []template.PayoutTier, roundingDenomination int64) ([]Payout, error) { if len(tiers) == 0 { return nil, fmt.Errorf("financial: no payout tiers") } if prizePool <= 0 { return nil, fmt.Errorf("financial: prize pool must be positive") } if roundingDenomination <= 0 { roundingDenomination = 1 // No rounding } payouts := make([]Payout, len(tiers)) var allocated int64 for i, tier := range tiers { // Raw amount from percentage (basis points / 10000) raw := prizePool * tier.PercentageBasisPoints / 10000 // Round DOWN to rounding denomination rounded := (raw / roundingDenomination) * roundingDenomination payouts[i] = Payout{ Position: tier.Position, Amount: rounded, Percentage: tier.PercentageBasisPoints, } allocated += rounded } // Assign remainder to 1st place (standard poker convention) remainder := prizePool - allocated if remainder != 0 { // Find position 1 for i := range payouts { if payouts[i].Position == 1 { payouts[i].Amount += remainder break } } } // CRITICAL ASSERTION: sum must equal prize pool var sum int64 for _, p := range payouts { sum += p.Amount } if sum != prizePool { // This should never happen. If it does, it's a bug. return nil, fmt.Errorf("financial: CRITICAL payout sum mismatch: sum=%d prizePool=%d (diff=%d)", sum, prizePool, sum-prizePool) } return payouts, nil } // ApplyPayouts creates payout transactions for each finishing position. func (e *Engine) ApplyPayouts(ctx context.Context, tournamentID string, payouts []Payout) error { operatorID := middleware.OperatorIDFromCtx(ctx) for _, p := range payouts { // Find player at this finishing position var playerID string err := e.db.QueryRowContext(ctx, `SELECT player_id FROM tournament_players WHERE tournament_id = ? AND finishing_position = ?`, tournamentID, p.Position, ).Scan(&playerID) if err == sql.ErrNoRows { continue // No player at this position yet } if err != nil { return fmt.Errorf("financial: find player at position %d: %w", p.Position, err) } tx := &Transaction{ ID: generateUUID(), TournamentID: tournamentID, PlayerID: playerID, Type: TxTypePayout, Amount: p.Amount, Chips: 0, OperatorID: operatorID, CreatedAt: time.Now().Unix(), } metaJSON, _ := json.Marshal(map[string]interface{}{ "position": p.Position, "percentage": p.Percentage, }) tx.Metadata = metaJSON if err := e.insertTransaction(ctx, tx); err != nil { return err } // Update player's prize_amount _, err = e.db.ExecContext(ctx, `UPDATE tournament_players SET prize_amount = ?, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, p.Amount, tournamentID, playerID, ) if err != nil { return fmt.Errorf("financial: update prize amount: %w", err) } // Audit entry tournamentIDPtr := &tournamentID _, _ = e.trail.Record(ctx, audit.AuditEntry{ TournamentID: tournamentIDPtr, Action: audit.ActionFinancialPayout, TargetType: "player", TargetID: playerID, NewState: metaJSON, }) } e.broadcast(tournamentID, "financial.payouts_applied", payouts) return nil } // CalculateBubblePrize proposes a bubble prize by shaving from top positions. func (e *Engine) CalculateBubblePrize(ctx context.Context, tournamentID string, amount int64) (*BubblePrizeProposal, error) { if amount <= 0 { return nil, fmt.Errorf("financial: bubble prize amount must be positive") } // Get current payouts payouts, err := e.CalculatePayouts(ctx, tournamentID) if err != nil { return nil, err } if len(payouts) < 2 { return nil, fmt.Errorf("financial: need at least 2 payout positions for bubble prize") } // Shave from top prizes proportionally (1st-3rd primarily, extending to 4th-5th if needed) funding, adjustedPayouts, err := redistributeForBubble(payouts, amount) if err != nil { return nil, err } fundedFromJSON, _ := json.Marshal(funding) // Insert proposal into bubble_prizes table res, err := e.db.ExecContext(ctx, `INSERT INTO bubble_prizes (tournament_id, amount, funded_from, status, created_at) VALUES (?, ?, ?, 'proposed', ?)`, tournamentID, amount, string(fundedFromJSON), time.Now().Unix(), ) if err != nil { return nil, fmt.Errorf("financial: insert bubble prize proposal: %w", err) } proposalID, _ := res.LastInsertId() return &BubblePrizeProposal{ ID: proposalID, TournamentID: tournamentID, Amount: amount, OriginalPayouts: payouts, AdjustedPayouts: adjustedPayouts, FundedFrom: fundedFromJSON, Status: "proposed", }, nil } // ConfirmBubblePrize confirms and applies a bubble prize proposal. func (e *Engine) ConfirmBubblePrize(ctx context.Context, tournamentID string, proposalID int64) error { // Verify proposal exists and is pending var status string var amount int64 err := e.db.QueryRowContext(ctx, "SELECT status, amount FROM bubble_prizes WHERE id = ? AND tournament_id = ?", proposalID, tournamentID, ).Scan(&status, &amount) if err == sql.ErrNoRows { return fmt.Errorf("financial: bubble prize proposal not found") } if err != nil { return fmt.Errorf("financial: load bubble prize: %w", err) } if status != "proposed" { return fmt.Errorf("financial: bubble prize already %s", status) } // Update status to confirmed _, err = e.db.ExecContext(ctx, "UPDATE bubble_prizes SET status = 'confirmed' WHERE id = ?", proposalID, ) if err != nil { return fmt.Errorf("financial: confirm bubble prize: %w", err) } // Audit metadataJSON, _ := json.Marshal(map[string]interface{}{ "proposal_id": proposalID, "amount": amount, }) tournamentIDPtr := &tournamentID _, _ = e.trail.Record(ctx, audit.AuditEntry{ TournamentID: tournamentIDPtr, Action: audit.ActionFinancialBubblePrize, TargetType: "tournament", TargetID: tournamentID, NewState: metadataJSON, }) e.broadcast(tournamentID, "financial.bubble_prize_confirmed", map[string]interface{}{ "proposal_id": proposalID, "amount": amount, }) return nil } // redistributeForBubble shaves from top positions to fund a bubble prize. // Shaves proportionally from 1st-3rd, extending to 4th-5th if needed. func redistributeForBubble(payouts []Payout, bubbleAmount int64) ([]FundingSource, []Payout, error) { // Determine how many positions to shave from (top 3, or top 5 if needed) maxShavePositions := 3 if maxShavePositions > len(payouts) { maxShavePositions = len(payouts) } // Calculate total from shaveable positions var shaveableTotal int64 for i := 0; i < maxShavePositions; i++ { shaveableTotal += payouts[i].Amount } // If top 3 can't cover it, extend to 5 if shaveableTotal < bubbleAmount && len(payouts) > maxShavePositions { maxShavePositions = 5 if maxShavePositions > len(payouts) { maxShavePositions = len(payouts) } shaveableTotal = 0 for i := 0; i < maxShavePositions; i++ { shaveableTotal += payouts[i].Amount } } if shaveableTotal < bubbleAmount { return nil, nil, fmt.Errorf("financial: bubble prize %d exceeds total shaveable amount %d", bubbleAmount, shaveableTotal) } // Proportionally shave adjusted := make([]Payout, len(payouts)) copy(adjusted, payouts) funding := make([]FundingSource, 0, maxShavePositions) var totalShaved int64 for i := 0; i < maxShavePositions; i++ { var shaveAmount int64 if i == maxShavePositions-1 { // Last position gets remainder shaveAmount = bubbleAmount - totalShaved } else { // Proportional share shaveAmount = bubbleAmount * payouts[i].Amount / shaveableTotal } if shaveAmount > adjusted[i].Amount { shaveAmount = adjusted[i].Amount } adjusted[i].Amount -= shaveAmount totalShaved += shaveAmount funding = append(funding, FundingSource{ Position: payouts[i].Position, ReductionAmount: shaveAmount, }) } return funding, adjusted, nil } // selectBracket finds the payout bracket matching the given entry count. func selectBracket(brackets []template.PayoutBracket, entryCount int) *template.PayoutBracket { for i := range brackets { if entryCount >= brackets[i].MinEntries && entryCount <= brackets[i].MaxEntries { return &brackets[i] } } // If entry count exceeds all brackets, use the last one if len(brackets) > 0 && entryCount > brackets[len(brackets)-1].MaxEntries { return &brackets[len(brackets)-1] } return nil } // loadRoundingDenomination loads the venue's rounding denomination. func (e *Engine) loadRoundingDenomination(ctx context.Context) int64 { var denom int64 err := e.db.QueryRowContext(ctx, "SELECT rounding_denomination FROM venue_settings WHERE id = 1").Scan(&denom) if err != nil { log.Printf("financial: load rounding denomination: %v (defaulting to 100)", err) return 100 // Default: 1.00 unit } return denom }