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>
This commit is contained in:
Mikkel Georgsen 2026-03-01 04:11:58 +01:00
parent e947ab1c47
commit 51153df8dd
2 changed files with 1896 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,658 @@
package financial
import (
"context"
"database/sql"
"testing"
_ "github.com/tursodatabase/go-libsql"
"github.com/felt-app/felt/internal/audit"
"github.com/felt-app/felt/internal/server/middleware"
"github.com/felt-app/felt/internal/template"
)
// testDB creates an in-memory SQLite database with the required schema.
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("libsql", "file::memory:?cache=shared")
if err != nil {
t.Fatalf("open test db: %v", err)
}
t.Cleanup(func() { db.Close() })
// Create minimal schema
stmts := []string{
`CREATE TABLE IF NOT EXISTS venue_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
venue_name TEXT NOT NULL DEFAULT '',
currency_code TEXT NOT NULL DEFAULT 'DKK',
currency_symbol TEXT NOT NULL DEFAULT 'kr',
rounding_denomination INTEGER NOT NULL DEFAULT 5000,
receipt_mode TEXT NOT NULL DEFAULT 'digital',
timezone TEXT NOT NULL DEFAULT 'Europe/Copenhagen',
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS buyin_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
buyin_amount INTEGER NOT NULL DEFAULT 0,
starting_chips INTEGER NOT NULL DEFAULT 0,
rake_total INTEGER NOT NULL DEFAULT 0,
bounty_amount INTEGER NOT NULL DEFAULT 0,
bounty_chip INTEGER NOT NULL DEFAULT 0,
rebuy_allowed INTEGER NOT NULL DEFAULT 0,
rebuy_cost INTEGER NOT NULL DEFAULT 0,
rebuy_chips INTEGER NOT NULL DEFAULT 0,
rebuy_rake INTEGER NOT NULL DEFAULT 0,
rebuy_limit INTEGER NOT NULL DEFAULT 0,
rebuy_level_cutoff INTEGER,
rebuy_time_cutoff_seconds INTEGER,
rebuy_chip_threshold INTEGER,
addon_allowed INTEGER NOT NULL DEFAULT 0,
addon_cost INTEGER NOT NULL DEFAULT 0,
addon_chips INTEGER NOT NULL DEFAULT 0,
addon_rake INTEGER NOT NULL DEFAULT 0,
addon_level_start INTEGER,
addon_level_end INTEGER,
reentry_allowed INTEGER NOT NULL DEFAULT 0,
reentry_limit INTEGER NOT NULL DEFAULT 0,
late_reg_level_cutoff INTEGER,
late_reg_time_cutoff_seconds INTEGER,
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS rake_splits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
buyin_config_id INTEGER NOT NULL,
category TEXT NOT NULL,
amount INTEGER NOT NULL DEFAULT 0,
UNIQUE(buyin_config_id, category)
)`,
`CREATE TABLE IF NOT EXISTS chip_sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS blind_structures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
is_builtin INTEGER NOT NULL DEFAULT 0,
game_type_default TEXT NOT NULL DEFAULT 'nlhe',
notes TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS payout_structures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS tournaments (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
template_id INTEGER,
chip_set_id INTEGER NOT NULL DEFAULT 1,
blind_structure_id INTEGER NOT NULL DEFAULT 1,
payout_structure_id INTEGER NOT NULL DEFAULT 1,
buyin_config_id INTEGER NOT NULL,
points_formula_id INTEGER,
status TEXT NOT NULL DEFAULT 'created',
min_players INTEGER NOT NULL DEFAULT 2,
max_players INTEGER,
early_signup_bonus_chips INTEGER NOT NULL DEFAULT 0,
early_signup_cutoff TEXT,
punctuality_bonus_chips INTEGER NOT NULL DEFAULT 0,
is_pko INTEGER NOT NULL DEFAULT 0,
current_level INTEGER NOT NULL DEFAULT 0,
clock_state TEXT NOT NULL DEFAULT 'stopped',
clock_remaining_ns INTEGER NOT NULL DEFAULT 0,
total_elapsed_ns INTEGER NOT NULL DEFAULT 0,
hand_for_hand INTEGER NOT NULL DEFAULT 0,
started_at INTEGER,
ended_at INTEGER,
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
nickname TEXT,
email TEXT,
phone TEXT,
photo_url TEXT,
notes TEXT,
custom_fields TEXT,
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS tournament_players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tournament_id TEXT NOT NULL,
player_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'registered',
seat_table_id INTEGER,
seat_position INTEGER,
buy_in_at INTEGER,
bust_out_at INTEGER,
bust_out_order INTEGER,
finishing_position INTEGER,
current_chips INTEGER NOT NULL DEFAULT 0,
rebuys INTEGER NOT NULL DEFAULT 0,
addons INTEGER NOT NULL DEFAULT 0,
reentries INTEGER NOT NULL DEFAULT 0,
bounty_value INTEGER NOT NULL DEFAULT 0,
bounties_collected INTEGER NOT NULL DEFAULT 0,
prize_amount INTEGER NOT NULL DEFAULT 0,
points_awarded INTEGER NOT NULL DEFAULT 0,
early_signup_bonus_applied INTEGER NOT NULL DEFAULT 0,
punctuality_bonus_applied INTEGER NOT NULL DEFAULT 0,
hitman_player_id TEXT,
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0,
UNIQUE(tournament_id, player_id)
)`,
`CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
tournament_id TEXT NOT NULL,
player_id TEXT NOT NULL,
type TEXT NOT NULL,
amount INTEGER NOT NULL DEFAULT 0,
chips INTEGER NOT NULL DEFAULT 0,
operator_id TEXT NOT NULL,
receipt_data TEXT,
undone INTEGER NOT NULL DEFAULT 0,
undone_by TEXT,
metadata TEXT,
created_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS audit_entries (
id TEXT PRIMARY KEY,
tournament_id TEXT,
timestamp INTEGER NOT NULL DEFAULT 0,
operator_id TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL DEFAULT '',
target_id TEXT NOT NULL DEFAULT '',
previous_state TEXT,
new_state TEXT,
metadata TEXT,
undone_by TEXT
)`,
// Seed required references
`INSERT INTO chip_sets (id, name) VALUES (1, 'Test Chips')`,
`INSERT INTO blind_structures (id, name) VALUES (1, 'Test Blinds')`,
`INSERT INTO payout_structures (id, name) VALUES (1, 'Test Payouts')`,
}
for _, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
t.Fatalf("exec schema: %s: %v", stmt[:50], err)
}
}
return db
}
// setupTestEngine creates an Engine with test dependencies.
func setupTestEngine(t *testing.T) (*Engine, *sql.DB) {
t.Helper()
db := testDB(t)
trail := audit.NewTrail(db, nil)
undo := audit.NewUndoEngine(trail)
buyinSvc := template.NewBuyinService(db)
eng := NewEngine(db, trail, undo, nil, buyinSvc)
return eng, db
}
// seedBuyinConfig creates a test buy-in config and returns its ID.
func seedBuyinConfig(t *testing.T, db *sql.DB, amount, chips, rake int64) int64 {
t.Helper()
res, err := db.Exec(
`INSERT INTO buyin_configs (name, buyin_amount, starting_chips, rake_total,
rebuy_allowed, rebuy_cost, rebuy_chips, rebuy_rake, rebuy_limit,
addon_allowed, addon_cost, addon_chips, addon_rake,
reentry_allowed, reentry_limit)
VALUES ('Test Buyin', ?, ?, ?, 1, ?, ?, ?, 3, 1, ?, ?, ?, 1, 2)`,
amount, chips, rake,
amount, chips, rake/2, // rebuy costs same as buyin, half rake
amount/2, chips/2, rake/4, // addon costs half, quarter rake
)
if err != nil {
t.Fatalf("seed buyin config: %v", err)
}
id, _ := res.LastInsertId()
// Add rake splits
if rake > 0 {
houseRake := rake * 60 / 100
staffRake := rake - houseRake
db.Exec("INSERT INTO rake_splits (buyin_config_id, category, amount) VALUES (?, 'house', ?)", id, houseRake)
db.Exec("INSERT INTO rake_splits (buyin_config_id, category, amount) VALUES (?, 'staff', ?)", id, staffRake)
}
return id
}
// seedTournament creates a test tournament.
func seedTournament(t *testing.T, db *sql.DB, id string, buyinConfigID int64, isPKO bool) {
t.Helper()
pko := 0
if isPKO {
pko = 1
}
_, err := db.Exec(
`INSERT INTO tournaments (id, name, buyin_config_id, is_pko, status)
VALUES (?, 'Test Tournament', ?, ?, 'running')`,
id, buyinConfigID, pko,
)
if err != nil {
t.Fatalf("seed tournament: %v", err)
}
}
// seedPlayer creates a test player and registers them in a tournament.
func seedPlayer(t *testing.T, db *sql.DB, tournamentID, playerID, status string) {
t.Helper()
db.Exec("INSERT OR IGNORE INTO players (id, name) VALUES (?, ?)", playerID, "Player "+playerID)
_, err := db.Exec(
"INSERT INTO tournament_players (tournament_id, player_id, status) VALUES (?, ?, ?)",
tournamentID, playerID, status,
)
if err != nil {
t.Fatalf("seed player: %v", err)
}
}
func ctxWithOperator() context.Context {
ctx := context.Background()
ctx = context.WithValue(ctx, middleware.OperatorIDKey, "op-test")
ctx = context.WithValue(ctx, middleware.OperatorRoleKey, "admin")
return ctx
}
func TestProcessBuyIn(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000) // 100 EUR buyin, 150 chips, 20 rake
seedTournament(t, db, "t1", cfgID, false)
seedPlayer(t, db, "t1", "p1", "registered")
tx, err := eng.ProcessBuyIn(ctx, "t1", "p1", false)
if err != nil {
t.Fatalf("process buyin: %v", err)
}
if tx.Amount != 10000 {
t.Errorf("expected amount 10000, got %d", tx.Amount)
}
if tx.Chips != 15000 {
t.Errorf("expected chips 15000, got %d", tx.Chips)
}
if tx.Type != TxTypeBuyIn {
t.Errorf("expected type buyin, got %s", tx.Type)
}
// Verify player state was updated
var status string
var chips int64
db.QueryRow("SELECT status, current_chips FROM tournament_players WHERE tournament_id = 't1' AND player_id = 'p1'").Scan(&status, &chips)
if status != "active" {
t.Errorf("expected player status 'active', got %q", status)
}
if chips != 15000 {
t.Errorf("expected player chips 15000, got %d", chips)
}
// Verify rake transactions were created
var rakeCount int
db.QueryRow("SELECT COUNT(*) FROM transactions WHERE tournament_id = 't1' AND type = 'rake'").Scan(&rakeCount)
if rakeCount != 2 { // house + staff
t.Errorf("expected 2 rake transactions, got %d", rakeCount)
}
}
func TestProcessBuyIn_LateRegClosed(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
// Set late reg cutoff to level 6
db.Exec("UPDATE buyin_configs SET late_reg_level_cutoff = 6 WHERE id = ?", cfgID)
seedTournament(t, db, "t1", cfgID, false)
// Simulate clock past cutoff
db.Exec("UPDATE tournaments SET clock_state = 'running', current_level = 7 WHERE id = 't1'")
seedPlayer(t, db, "t1", "p1", "registered")
_, err := eng.ProcessBuyIn(ctx, "t1", "p1", false)
if err != ErrLateRegistrationClosed {
t.Fatalf("expected ErrLateRegistrationClosed, got: %v", err)
}
}
func TestProcessBuyIn_AdminOverride(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
db.Exec("UPDATE buyin_configs SET late_reg_level_cutoff = 6 WHERE id = ?", cfgID)
seedTournament(t, db, "t1", cfgID, false)
db.Exec("UPDATE tournaments SET clock_state = 'running', current_level = 7 WHERE id = 't1'")
seedPlayer(t, db, "t1", "p1", "registered")
// With override=true, should succeed
tx, err := eng.ProcessBuyIn(ctx, "t1", "p1", true)
if err != nil {
t.Fatalf("expected admin override to succeed: %v", err)
}
if tx.Amount != 10000 {
t.Errorf("expected amount 10000, got %d", tx.Amount)
}
}
func TestProcessRebuy(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
seedTournament(t, db, "t1", cfgID, false)
seedPlayer(t, db, "t1", "p1", "active")
tx, err := eng.ProcessRebuy(ctx, "t1", "p1")
if err != nil {
t.Fatalf("process rebuy: %v", err)
}
if tx.Type != TxTypeRebuy {
t.Errorf("expected type rebuy, got %s", tx.Type)
}
// Verify rebuy count incremented
var rebuys int
db.QueryRow("SELECT rebuys FROM tournament_players WHERE tournament_id = 't1' AND player_id = 'p1'").Scan(&rebuys)
if rebuys != 1 {
t.Errorf("expected rebuys 1, got %d", rebuys)
}
}
func TestProcessRebuy_LimitReached(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
seedTournament(t, db, "t1", cfgID, false)
seedPlayer(t, db, "t1", "p1", "active")
// Set rebuy count to limit
db.Exec("UPDATE tournament_players SET rebuys = 3 WHERE tournament_id = 't1' AND player_id = 'p1'")
_, err := eng.ProcessRebuy(ctx, "t1", "p1")
if err != ErrRebuyLimitReached {
t.Fatalf("expected ErrRebuyLimitReached, got: %v", err)
}
}
func TestProcessAddOn(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
seedTournament(t, db, "t1", cfgID, false)
seedPlayer(t, db, "t1", "p1", "active")
tx, err := eng.ProcessAddOn(ctx, "t1", "p1")
if err != nil {
t.Fatalf("process addon: %v", err)
}
if tx.Type != TxTypeAddon {
t.Errorf("expected type addon, got %s", tx.Type)
}
// Verify addon count
var addons int
db.QueryRow("SELECT addons FROM tournament_players WHERE tournament_id = 't1' AND player_id = 'p1'").Scan(&addons)
if addons != 1 {
t.Errorf("expected addons 1, got %d", addons)
}
}
func TestProcessAddOn_AlreadyUsed(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
seedTournament(t, db, "t1", cfgID, false)
seedPlayer(t, db, "t1", "p1", "active")
db.Exec("UPDATE tournament_players SET addons = 1 WHERE tournament_id = 't1' AND player_id = 'p1'")
_, err := eng.ProcessAddOn(ctx, "t1", "p1")
if err != ErrAddonAlreadyUsed {
t.Fatalf("expected ErrAddonAlreadyUsed, got: %v", err)
}
}
func TestProcessReEntry(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
seedTournament(t, db, "t1", cfgID, false)
seedPlayer(t, db, "t1", "p1", "busted") // Must be busted
tx, err := eng.ProcessReEntry(ctx, "t1", "p1")
if err != nil {
t.Fatalf("process reentry: %v", err)
}
if tx.Type != TxTypeReentry {
t.Errorf("expected type reentry, got %s", tx.Type)
}
// Player should be reactivated
var status string
db.QueryRow("SELECT status FROM tournament_players WHERE tournament_id = 't1' AND player_id = 'p1'").Scan(&status)
if status != "active" {
t.Errorf("expected player reactivated to 'active', got %q", status)
}
}
func TestProcessReEntry_NotBusted(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
seedTournament(t, db, "t1", cfgID, false)
seedPlayer(t, db, "t1", "p1", "active") // Active, not busted
_, err := eng.ProcessReEntry(ctx, "t1", "p1")
if err != ErrPlayerNotBusted {
t.Fatalf("expected ErrPlayerNotBusted, got: %v", err)
}
}
func TestProcessBountyTransfer_PKO(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
db.Exec("UPDATE buyin_configs SET bounty_amount = 5000 WHERE id = ?", cfgID)
seedTournament(t, db, "t1", cfgID, true) // PKO
seedPlayer(t, db, "t1", "p1", "busted") // Eliminated
seedPlayer(t, db, "t1", "p2", "active") // Hitman
// Set bounty values
db.Exec("UPDATE tournament_players SET bounty_value = 5000 WHERE tournament_id = 't1' AND player_id = 'p1'")
db.Exec("UPDATE tournament_players SET bounty_value = 2500 WHERE tournament_id = 't1' AND player_id = 'p2'")
err := eng.ProcessBountyTransfer(ctx, "t1", "p1", "p2")
if err != nil {
t.Fatalf("process bounty transfer: %v", err)
}
// Verify: p1's bounty cleared
var p1Bounty int64
db.QueryRow("SELECT bounty_value FROM tournament_players WHERE tournament_id = 't1' AND player_id = 'p1'").Scan(&p1Bounty)
if p1Bounty != 0 {
t.Errorf("expected eliminated player bounty 0, got %d", p1Bounty)
}
// p2's bounty increased by half of p1's bounty (2500)
var p2Bounty int64
db.QueryRow("SELECT bounty_value FROM tournament_players WHERE tournament_id = 't1' AND player_id = 'p2'").Scan(&p2Bounty)
expected := int64(2500 + 2500) // original 2500 + half of 5000
if p2Bounty != expected {
t.Errorf("expected hitman bounty %d, got %d", expected, p2Bounty)
}
// Verify transactions created
var collectCount, paidCount int
db.QueryRow("SELECT COUNT(*) FROM transactions WHERE tournament_id = 't1' AND type = 'bounty_collected'").Scan(&collectCount)
db.QueryRow("SELECT COUNT(*) FROM transactions WHERE tournament_id = 't1' AND type = 'bounty_paid'").Scan(&paidCount)
if collectCount != 1 {
t.Errorf("expected 1 bounty_collected tx, got %d", collectCount)
}
if paidCount != 1 {
t.Errorf("expected 1 bounty_paid tx, got %d", paidCount)
}
// Verify cash portion is correct (half of 5000 = 2500)
var cashAmount int64
db.QueryRow("SELECT amount FROM transactions WHERE tournament_id = 't1' AND type = 'bounty_collected' AND player_id = 'p2'").Scan(&cashAmount)
if cashAmount != 2500 {
t.Errorf("expected cash portion 2500, got %d", cashAmount)
}
}
func TestUndoTransaction(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
seedTournament(t, db, "t1", cfgID, false)
seedPlayer(t, db, "t1", "p1", "registered")
// Buy in
tx, err := eng.ProcessBuyIn(ctx, "t1", "p1", false)
if err != nil {
t.Fatalf("process buyin: %v", err)
}
// Undo
err = eng.UndoTransaction(ctx, tx.ID)
if err != nil {
t.Fatalf("undo transaction: %v", err)
}
// Verify transaction is marked undone
var undone bool
db.QueryRow("SELECT undone FROM transactions WHERE id = ?", tx.ID).Scan(&undone)
if !undone {
t.Error("expected transaction to be marked undone")
}
}
func TestIsLateRegistrationOpen(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
// Set level cutoff to 6 AND time cutoff to 5400 seconds (90 min)
db.Exec("UPDATE buyin_configs SET late_reg_level_cutoff = 6, late_reg_time_cutoff_seconds = 5400 WHERE id = ?", cfgID)
seedTournament(t, db, "t1", cfgID, false)
// Clock stopped: always open
open, err := eng.IsLateRegistrationOpen(ctx, "t1")
if err != nil {
t.Fatalf("check late reg: %v", err)
}
if !open {
t.Error("expected late reg open when clock stopped")
}
// Clock running, level 3: open
db.Exec("UPDATE tournaments SET clock_state = 'running', current_level = 3 WHERE id = 't1'")
open, _ = eng.IsLateRegistrationOpen(ctx, "t1")
if !open {
t.Error("expected late reg open at level 3")
}
// Clock running, level 7: closed (past level cutoff)
db.Exec("UPDATE tournaments SET current_level = 7 WHERE id = 't1'")
open, _ = eng.IsLateRegistrationOpen(ctx, "t1")
if open {
t.Error("expected late reg closed at level 7")
}
// Clock running, level 3 but time past cutoff
db.Exec("UPDATE tournaments SET current_level = 3, total_elapsed_ns = 6000000000000 WHERE id = 't1'") // 6000 seconds > 5400
open, _ = eng.IsLateRegistrationOpen(ctx, "t1")
if open {
t.Error("expected late reg closed when time past cutoff")
}
}
func TestGetTransactions(t *testing.T) {
eng, db := setupTestEngine(t)
ctx := ctxWithOperator()
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
seedTournament(t, db, "t1", cfgID, false)
seedPlayer(t, db, "t1", "p1", "registered")
seedPlayer(t, db, "t1", "p2", "registered")
eng.ProcessBuyIn(ctx, "t1", "p1", false)
eng.ProcessBuyIn(ctx, "t1", "p2", false)
txs, err := eng.GetTransactions(ctx, "t1")
if err != nil {
t.Fatalf("get transactions: %v", err)
}
// Should have 2 buyins + 4 rake transactions (2 per player: house + staff)
if len(txs) != 6 {
t.Errorf("expected 6 transactions, got %d", len(txs))
}
// Player-specific
ptxs, err := eng.GetPlayerTransactions(ctx, "t1", "p1")
if err != nil {
t.Fatalf("get player transactions: %v", err)
}
if len(ptxs) != 3 { // 1 buyin + 2 rake
t.Errorf("expected 3 player transactions, got %d", len(ptxs))
}
}
func TestScaleRakeSplits(t *testing.T) {
eng, _ := setupTestEngine(t)
original := []template.RakeSplit{
{Category: "house", Amount: 1200},
{Category: "staff", Amount: 800},
}
// Scale from 2000 to 1000
scaled := eng.scaleRakeSplits(original, 2000, 1000)
if len(scaled) != 2 {
t.Fatalf("expected 2 scaled splits, got %d", len(scaled))
}
// Verify sum equals new total
var sum int64
for _, s := range scaled {
sum += s.Amount
}
if sum != 1000 {
t.Errorf("expected scaled sum 1000, got %d", sum)
}
}