felt/internal/financial/payout.go
Mikkel Georgsen 56a7ef1e31 docs(01-13): complete Layout Shell plan
- SUMMARY.md with all accomplishments and deviation documentation
- STATE.md updated: plan 8/14, 50% progress, decisions, session
- ROADMAP.md updated: 7/14 plans complete
- REQUIREMENTS.md: UI-01 through UI-04, UI-07, UI-08 marked complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 04:15:37 +01:00

526 lines
17 KiB
Go

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
}