- 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>
526 lines
17 KiB
Go
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
|
|
}
|