14 plans in 6 waves covering all 68 requirements for the Tournament Engine phase. Includes research (go-libsql, NATS JetStream, Svelte 5 runes, ICM complexity), plan verification (2 iterations), and user feedback (hand-for-hand UX, SEAT-06 reword, re-entry semantics, integration test, DKK defaults, JWT 7-day expiry, clock tap safety). Wave structure: 1: A (scaffold), B (schema) 2: C (auth/audit), D (clock), E (templates), J (frontend scaffold) 3: F (financial), H (seating), M (layout shell) 4: G (player management) 5: I (tournament lifecycle) 6: K (overview/financials), L (players), N (tables/more) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
13 KiB
Plan F: Financial Engine
wave: 3 depends_on: [01-PLAN-C, 01-PLAN-E] files_modified:
- internal/financial/engine.go
- internal/financial/payout.go
- internal/financial/receipt.go
- internal/server/routes/financials.go
- internal/financial/engine_test.go
- internal/financial/payout_test.go autonomous: true requirements: [FIN-03, FIN-04, FIN-07, FIN-08, FIN-09, FIN-12, FIN-13, FIN-14]
Goal
The financial engine processes buy-ins, rebuys, add-ons, re-entries, and bounty transfers. It calculates prize pools from all inputs, handles guaranteed pots, tracks rake splits (including season reserve), and generates receipts for every transaction. All math uses int64 cents. Every transaction writes an audit entry and can be undone. A CI gate test proves sum of payouts always equals prize pool.
Context
- All money is int64 cents (ARCH-03) — already enforced in schema (Plan B)
- Buy-in config is a building block — already created in Plan E
- Payout structures with brackets — already created in Plan E
- Audit trail + undo — already created in Plan C
- Late registration soft lock with admin override — logged in audit trail (CONTEXT.md)
- See 01-RESEARCH.md Pattern 5 (Int64 Financial Math), Pitfall 4 (Payout Rounding)
User Decisions (from CONTEXT.md)
- Entry count = unique entries only — not rebuys or add-ons (for payout bracket selection)
- Prize rounding — round down to nearest venue-configured denomination
- Bubble prize — fast and prominent, funded by shaving top prizes
- PKO (Progressive Knockout) — half bounty to hitman, half added to own bounty
- Receipts configurable per venue — off / digital / print / both
- Late registration soft lock with admin override
Tasks
**1. Financial Engine** (`internal/financial/engine.go`): - `FinancialEngine` struct with db, audit trail, nats publisher-
ProcessBuyIn(ctx, tournamentID, playerID string, override bool) (*Transaction, error):- Load tournament's buyin config
- Check late registration eligibility:
- Load tournament clock state (current level, elapsed time)
- If past cutoff AND not override → return
ErrLateRegistrationClosed - If past cutoff AND override → allow, log admin override in audit trail (CONTEXT.md: admin override logged)
- If no cutoff → always allow
- Check max players limit
- Create transaction record:
- Type: "buyin"
- Amount: buyin_amount (int64 cents)
- Chips: starting_chips + any applicable bonuses (early signup, punctuality)
- Create rake split transactions (one per category: house, staff, league, season_reserve)
- If PKO tournament: create bounty initialization (bounty_amount → half initial bounty value on player)
- Persist all to transactions table
- Record audit entry with full state
- Broadcast via WebSocket
- Return transaction for receipt generation
-
ProcessRebuy(ctx, tournamentID, playerID string) (*Transaction, error):- Check player is active in tournament
- Check rebuy eligibility:
- Rebuy allowed in buyin config
- Player's rebuy count < rebuy_limit (0 = unlimited)
- Current level/time within rebuy cutoff
- Player's chip count <= rebuy_chip_threshold (if configured)
- Create transaction record (type: "rebuy")
- Increment player's rebuy count
- Record audit entry
- Broadcast
- Return transaction
-
ProcessAddOn(ctx, tournamentID, playerID string) (*Transaction, error):- Check addon allowed and within addon window (level_start to level_end)
- Check player hasn't already added on (addon is typically once per tournament)
- Create transaction, increment addon count
- Record audit entry, broadcast, return
-
ProcessReEntry(ctx, tournamentID, playerID string) (*Transaction, error):- Re-entry is distinct from rebuy (FIN-04): player must be busted first
- Check player status is "busted"
- Check reentry allowed and within reentry limit
- Reactivate player with fresh starting chips
- Increment reentry count (does NOT count as new unique entry for payout brackets)
- Process like a buy-in financially (same cost, rake)
- Re-seat player (auto-seat)
- Record audit entry, broadcast, return
-
ProcessBountyTransfer(ctx, tournamentID, eliminatedPlayerID, hitmanPlayerID string) error:- For PKO tournaments (FIN-07):
- Eliminated player's bounty value → half goes to hitman as cash prize, half adds to hitman's own bounty
- Track the chain in transaction metadata
- Create bounty_collected transaction for hitman
- Create bounty_paid transaction for eliminated player
- For fixed bounty (non-progressive):
- Full bounty amount goes to hitman
- Record audit entry with bounty chain details
- For PKO tournaments (FIN-07):
-
UndoTransaction(ctx, transactionID string) error:- Use undo engine (Plan C) to create reversal audit entry
- Reverse the financial effects:
- Subtract chips from player
- Decrement rebuy/addon/reentry count
- If buyin undo: remove player from tournament (status → removed)
- If bounty undo: reverse bounty transfer
- Trigger re-ranking (will be called by player management in Plan G)
- Broadcast updates
-
GetTransactions(ctx, tournamentID string) ([]Transaction, error)— all transactions for a tournament -
GetPlayerTransactions(ctx, tournamentID, playerID string) ([]Transaction, error)— player-specific
2. Late Registration Logic:
IsLateRegistrationOpen(ctx, tournamentID string) (bool, error):- Check buyin config cutoffs (level AND/OR time — FIN-03: "end of Level 6 or first 90 minutes, whichever comes first")
- Load current clock state
- Both conditions checked: if either cutoff is exceeded, late reg is closed
- Return true if still open
3. Season Withholding (FIN-12):
- When processing rake splits, if a "season_reserve" category exists, that portion is earmarked
- Track in a
season_reservessummary (aggregated from rake_split transactions) - API endpoint to query season reserves:
GET /api/v1/season-reserves
Verification:
- Buy-in creates correct transaction with rake splits
- Rebuy respects limits and cutoffs
- Add-on respects window
- Re-entry requires busted status
- PKO bounty transfer splits correctly (half cash, half to bounty)
- Late registration cutoff enforced (level AND time conditions)
- Admin override bypasses late reg with audit log
- Transaction undo reverses all effects
-
CalculatePayouts(ctx, tournamentID string) ([]Payout, error):- Load prize pool and entry count
- Load payout structure, select correct bracket by entry count (unique entries only)
- Load venue rounding denomination
- Calculate each position's payout:
- Raw amount = FinalPrizePool * tier.PercentageBasisPoints / 10000
- Rounded = (raw / roundingDenomination) * roundingDenomination — always round DOWN
- Assign remainder to 1st place (standard poker convention)
- Return list of Payout structs:
{Position int, Amount int64, Percentage int64} - CRITICAL: Assert
sum(payouts) == FinalPrizePool— if not, panic (this is a bug)
-
ApplyPayouts(ctx, tournamentID string, payouts []Payout) error:- For each payout, create a "payout" transaction for the player in that finishing position
- Record audit entries
- Broadcast updates
2. Bubble Prize (from CONTEXT.md):
CalculateBubblePrize(ctx, tournamentID string, amount int64) (*BubblePrizeProposal, error):- Load current payouts
- Calculate redistribution: shave from top prizes (proportionally from 1st-3rd primarily, extending to 4th-5th if needed)
- Return proposal showing original and adjusted payouts for each position
ConfirmBubblePrize(ctx, tournamentID string, proposalID string) error:- Apply the redistributed payout structure
- Add bubble prize position to payouts
- Record audit entry
3. Receipt Generation (internal/financial/receipt.go):
GenerateReceipt(ctx, transaction Transaction) (*Receipt, error):- Build receipt data from transaction:
- Venue name, tournament name, date/time
- Player name
- Transaction type and amount
- Chips received
- Running totals (total entries, rebuys, addons for this player)
- Receipt number (sequential per tournament)
- Return Receipt struct with all fields (rendering is frontend's job for digital, or ESC/POS for print — defer print to later)
- Build receipt data from transaction:
GetReceipt(ctx, transactionID string) (*Receipt, error)— retrieve saved receiptReprintReceipt(ctx, transactionID string) (*Receipt, error)— FIN-14: reprint capability (same receipt data, new print timestamp)
4. API Routes (internal/server/routes/financials.go):
GET /api/v1/tournaments/{id}/prize-pool— current prize pool summaryGET /api/v1/tournaments/{id}/payouts— calculated payout tableGET /api/v1/tournaments/{id}/payouts/preview— preview with current entries (not applied yet)POST /api/v1/tournaments/{id}/bubble-prize— body:{"amount": 5000}→ proposalPOST /api/v1/tournaments/{id}/bubble-prize/{proposalId}/confirm— confirm bubble prizeGET /api/v1/tournaments/{id}/transactions— all transactionsGET /api/v1/tournaments/{id}/transactions/{txId}/receipt— get receiptPOST /api/v1/tournaments/{id}/transactions/{txId}/reprint— reprint receiptGET /api/v1/season-reserves— season withholding summary
5. CI Gate Test (internal/financial/payout_test.go):
- Property-based test: generate random prize pools (1000 to 10,000,000 cents), random payout structures (2-20 positions), random rounding denominations (100, 500, 1000, 5000)
- For each combination: assert
sum(payouts) == prizePool— ZERO deviation - Run at least 10,000 random combinations
- This test MUST pass in CI — it's the financial integrity gate
- Also test:
- Guaranteed pot: pool < guarantee → house contribution makes up difference
- Bubble prize: redistributed payouts still sum to prize pool + bubble amount
- Rounding never creates money (never rounds up)
- Entry count uses unique entries only (not rebuys)
Verification:
- Prize pool calculation matches manual calculation for test scenarios
- Payouts sum to exactly prize pool in all cases (CI gate test)
- Bubble prize redistribution is correct and prominent
- Receipts contain all required information
- Season withholding is tracked separately
- Guaranteed pot fills shortfall correctly
Verification Criteria
- Buy-in, rebuy, add-on, and re-entry flows produce correct transactions
- Late registration cutoff works (level AND/OR time — whichever comes first)
- Admin override for late reg is logged in audit trail
- PKO bounty transfer splits correctly (half cash, half to own bounty)
- Prize pool calculation includes all contributions minus rake
- Guaranteed pot: house covers shortfall
- Payout rounding always rounds DOWN, remainder to 1st place
- CI gate test: sum(payouts) == prize_pool across 10,000+ random inputs
- Bubble prize redistribution is correct and traceable
- Receipts generated for every financial transaction
- Transaction undo reverses all effects with audit trail
- Season withholding (reserve rake) tracked separately
Must-Haves (Goal-Backward)
- All financial math uses int64 cents — zero float64
- CI gate test proves sum(payouts) == prize_pool, always, zero deviation
- Late registration with admin override (soft lock, not hard lock)
- PKO bounty half-split with chain tracking
- Prize pool auto-calculation from all inputs
- Guaranteed pot support (house covers shortfall)
- Every financial action generates a receipt
- Transaction editing with audit trail and receipt reprint
- Bubble prize creation is fast and accessible