felt/internal/financial/engine.go
Mikkel Georgsen 51153df8dd feat(01-06): implement financial transaction engine
- ProcessBuyIn with late registration cutoff and admin override
- ProcessRebuy with limit, level/time cutoff, and chip threshold checks
- ProcessAddOn with window validation and single-use enforcement
- ProcessReEntry requiring busted status with player reactivation
- ProcessBountyTransfer with PKO half-split and fixed bounty modes
- UndoTransaction reversing all financial effects
- IsLateRegistrationOpen checking both level AND time cutoffs
- GetSeasonReserves for season withholding tracking
- Rake split transactions per category (house, staff, league, season_reserve)
- Full audit trail integration for every transaction
- WebSocket broadcast for real-time updates
- 14 passing tests covering all flows

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

1239 lines
36 KiB
Go

// 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])
}