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>
This commit is contained in:
parent
7f91301efa
commit
56a7ef1e31
7 changed files with 1469 additions and 22 deletions
|
|
@ -213,14 +213,14 @@ Requirements for Phase 1 (Development Focus: Live Tournament Management). Each m
|
|||
|
||||
### Operator UI
|
||||
|
||||
- [ ] **UI-01**: Mobile-first bottom tab bar (Overview, Players, Tables, Financials, More)
|
||||
- [ ] **UI-02**: Floating Action Button (FAB) for quick actions (Bust, Buy In, Rebuy, Add-On, Pause/Resume)
|
||||
- [ ] **UI-03**: Persistent header showing clock, level, blinds, player count
|
||||
- [ ] **UI-04**: Desktop/laptop sidebar navigation with wider content area
|
||||
- [x] **UI-01**: Mobile-first bottom tab bar (Overview, Players, Tables, Financials, More)
|
||||
- [x] **UI-02**: Floating Action Button (FAB) for quick actions (Bust, Buy In, Rebuy, Add-On, Pause/Resume)
|
||||
- [x] **UI-03**: Persistent header showing clock, level, blinds, player count
|
||||
- [x] **UI-04**: Desktop/laptop sidebar navigation with wider content area
|
||||
- [x] **UI-05**: Catppuccin Mocha dark theme (default) and Latte light theme
|
||||
- [x] **UI-06**: 48px minimum touch targets, press-state animations, loading states
|
||||
- [ ] **UI-07**: Toast notifications (success, info, warning, error) with auto-dismiss
|
||||
- [ ] **UI-08**: Data tables with sort, sticky header, search/filter, swipe actions (mobile)
|
||||
- [x] **UI-07**: Toast notifications (success, info, warning, error) with auto-dismiss
|
||||
- [x] **UI-08**: Data tables with sort, sticky header, search/filter, swipe actions (mobile)
|
||||
|
||||
## v2 Requirements
|
||||
|
||||
|
|
@ -365,14 +365,14 @@ Which phases cover which requirements. Updated during roadmap reorganization.
|
|||
| SEAT-07 | Phase 1 | Pending |
|
||||
| SEAT-08 | Phase 1 | Pending |
|
||||
| SEAT-09 | Phase 1 | Pending |
|
||||
| UI-01 | Phase 1 | Pending |
|
||||
| UI-02 | Phase 1 | Pending |
|
||||
| UI-03 | Phase 1 | Pending |
|
||||
| UI-04 | Phase 1 | Pending |
|
||||
| UI-01 | Phase 1 | Complete |
|
||||
| UI-02 | Phase 1 | Complete |
|
||||
| UI-03 | Phase 1 | Complete |
|
||||
| UI-04 | Phase 1 | Complete |
|
||||
| UI-05 | Phase 1 | Complete |
|
||||
| UI-06 | Phase 1 | Complete |
|
||||
| UI-07 | Phase 1 | Pending |
|
||||
| UI-08 | Phase 1 | Pending |
|
||||
| UI-07 | Phase 1 | Complete |
|
||||
| UI-08 | Phase 1 | Complete |
|
||||
| DISP-01 | Phase 2 | Pending |
|
||||
| DISP-02 | Phase 2 | Pending |
|
||||
| DISP-03 | Phase 2 | Pending |
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ progress:
|
|||
total_phases: 1
|
||||
completed_phases: 0
|
||||
total_plans: 14
|
||||
completed_plans: 6
|
||||
completed_plans: 7
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
|
@ -23,27 +23,27 @@ See: .planning/PROJECT.md (updated 2026-02-28)
|
|||
## Current Position
|
||||
|
||||
Phase: 1 of 7 (Tournament Engine)
|
||||
Plan: 7 of 14 in current phase
|
||||
Plan: 8 of 14 in current phase
|
||||
Status: Executing Phase 1
|
||||
Last activity: 2026-03-01 — Completed Plan C (Authentication + Audit Trail + Undo Engine)
|
||||
Last activity: 2026-03-01 — Completed Plan M (Layout Shell)
|
||||
|
||||
Progress: [████░░░░░░] 43%
|
||||
Progress: [█████░░░░░] 50%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
- Total plans completed: 6
|
||||
- Average duration: 9min
|
||||
- Total execution time: 0.88 hours
|
||||
- Total plans completed: 7
|
||||
- Average duration: 8min
|
||||
- Total execution time: 0.97 hours
|
||||
|
||||
**By Phase:**
|
||||
|
||||
| Phase | Plans | Total | Avg/Plan |
|
||||
|-------|-------|-------|----------|
|
||||
| 01-tournament-engine | 6 | 53min | 9min |
|
||||
| 01-tournament-engine | 7 | 58min | 8min |
|
||||
|
||||
**Recent Trend:**
|
||||
- Last 5 plans: 01-02 (10min), 01-10 (5min), 01-04 (8min), 01-05 (10min), 01-03 (5min)
|
||||
- Last 5 plans: 01-10 (5min), 01-04 (8min), 01-05 (10min), 01-03 (5min), 01-13 (5min)
|
||||
- Trend: accelerating
|
||||
|
||||
*Updated after each plan completion*
|
||||
|
|
@ -82,6 +82,10 @@ Recent decisions affecting current work:
|
|||
- [01-03]: AuditRecorder callback breaks import cycle between auth and audit packages
|
||||
- [01-03]: NATS publish best-effort (logged, not fatal) to avoid audit blocking mutations
|
||||
- [01-03]: Undo creates reversal entry, only marks undone_by on original (never deletes)
|
||||
- [01-13]: div with role=tablist (not nav) for bottom tabs to avoid Svelte a11y conflict
|
||||
- [01-13]: FAB actions dispatched via callback prop for centralized routing in layout
|
||||
- [01-13]: Multi-tournament state is a separate store from singleton tournament state
|
||||
- [01-13]: DataTable uses Record<string,unknown> with render functions (not generics) for Svelte compat
|
||||
|
||||
### Pending Todos
|
||||
|
||||
|
|
@ -99,5 +103,5 @@ None yet.
|
|||
## Session Continuity
|
||||
|
||||
Last session: 2026-03-01
|
||||
Stopped at: Completed 01-03-PLAN.md (Authentication + Audit Trail + Undo Engine)
|
||||
Stopped at: Completed 01-13-PLAN.md (Layout Shell)
|
||||
Resume file: None
|
||||
|
|
|
|||
168
.planning/phases/01-tournament-engine/01-13-SUMMARY.md
Normal file
168
.planning/phases/01-tournament-engine/01-13-SUMMARY.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
---
|
||||
phase: 01-tournament-engine
|
||||
plan: 13
|
||||
subsystem: ui
|
||||
tags: [sveltekit, svelte5, catppuccin, responsive, mobile-first, touch-targets, toast, datatable, fab]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 01-tournament-engine
|
||||
provides: SvelteKit SPA scaffold, Catppuccin theme, WS/API clients, auth/tournament state stores
|
||||
provides:
|
||||
- Persistent header with live clock, level, blinds, player count
|
||||
- Mobile bottom tab bar with 5 navigation tabs (48px touch targets)
|
||||
- Desktop sidebar navigation (>=768px breakpoint)
|
||||
- Floating action button (FAB) with context-aware quick actions
|
||||
- Toast notification system (success/info/warning/error with auto-dismiss)
|
||||
- Reusable DataTable component (sort, search, swipe actions, skeleton loading)
|
||||
- Multi-tournament tab selector for 2+ active tournaments
|
||||
- Loading components (spinner, skeleton, full-page)
|
||||
- Root layout shell with auth guard and responsive structure
|
||||
affects: [01-14]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [responsive-layout-shell, mobile-bottom-tabs-desktop-sidebar, fab-pattern, toast-runes-store, datatable-generic, multi-tournament-routing]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- frontend/src/lib/components/Header.svelte
|
||||
- frontend/src/lib/components/BottomTabs.svelte
|
||||
- frontend/src/lib/components/Sidebar.svelte
|
||||
- frontend/src/lib/components/FAB.svelte
|
||||
- frontend/src/lib/components/Toast.svelte
|
||||
- frontend/src/lib/components/DataTable.svelte
|
||||
- frontend/src/lib/components/TournamentTabs.svelte
|
||||
- frontend/src/lib/components/Loading.svelte
|
||||
- frontend/src/lib/stores/toast.svelte.ts
|
||||
- frontend/src/lib/stores/multi-tournament.svelte.ts
|
||||
- frontend/src/routes/overview/+page.svelte
|
||||
- frontend/src/routes/players/+page.svelte
|
||||
- frontend/src/routes/tables/+page.svelte
|
||||
- frontend/src/routes/financials/+page.svelte
|
||||
- frontend/src/routes/more/+page.svelte
|
||||
modified:
|
||||
- frontend/src/routes/+layout.svelte
|
||||
- frontend/src/routes/+page.svelte
|
||||
|
||||
key-decisions:
|
||||
- "Svelte 5 div with role=tablist instead of nav element to avoid a11y conflict with interactive role on non-interactive element"
|
||||
- "FAB actions dispatched via callback prop (onaction) to parent layout for centralized action handling"
|
||||
- "Multi-tournament state as separate store (not merged into tournament store) for clean separation of concerns"
|
||||
- "DataTable uses generic Record<string,unknown> with column render functions rather than generics for Svelte component compatibility"
|
||||
|
||||
patterns-established:
|
||||
- "Layout shell: fixed header + fixed bottom tabs (mobile) / sidebar (desktop) + scrollable content area"
|
||||
- "Toast store: Svelte 5 runes class with auto-dismiss timers and type-based durations"
|
||||
- "DataTable: column config driven with hideMobile flag for responsive column visibility"
|
||||
- "FAB: backdrop overlay + ESC dismiss + context-aware action filtering"
|
||||
|
||||
requirements-completed: [UI-01, UI-02, UI-03, UI-04, UI-07, UI-08]
|
||||
|
||||
# Metrics
|
||||
duration: 5min
|
||||
completed: 2026-03-01
|
||||
---
|
||||
|
||||
# Plan 13: Layout Shell -- Header, Tabs, FAB, Toast, Data Table Summary
|
||||
|
||||
**Mobile-first layout shell with persistent clock header, bottom tabs/desktop sidebar, expandable FAB, toast notifications, and generic sortable DataTable**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 5 min
|
||||
- **Started:** 2026-03-01T03:07:29Z
|
||||
- **Completed:** 2026-03-01T03:13:27Z
|
||||
- **Tasks:** 1 (compound task with 9 sub-items)
|
||||
- **Files modified:** 74
|
||||
|
||||
## Accomplishments
|
||||
- Persistent header fixed at top showing clock countdown (MM:SS), level number, blinds (SB/BB), ante, player count with red pulse animation in final 10s and PAUSED/BREAK badges
|
||||
- Mobile bottom tab bar (5 tabs: Overview, Players, Tables, Financials, More) with 48px touch targets, hidden on desktop
|
||||
- Desktop sidebar (>=768px) with vertical navigation replacing bottom tabs, active item highlight with accent border
|
||||
- Floating Action Button expanding to Bust/Buy In/Rebuy/Add-On/Pause-Resume with backdrop dismiss and ESC key support
|
||||
- Toast notification system using Svelte 5 runes: success (3s), info (4s), warning (5s), error (8s) with auto-dismiss, color-coded using Catppuccin palette
|
||||
- Reusable DataTable component: sortable columns (asc/desc), sticky header, search/filter across all columns, mobile swipe actions, skeleton loading, empty state, responsive column hiding
|
||||
- Multi-tournament tab selector: horizontal scrollable tabs with status dot indicators, fast switching without re-fetch
|
||||
- Loading components: spinner (sm/md/lg), skeleton placeholder rows, full-page loading overlay
|
||||
- Root layout shell with auth guard (redirect to /login if unauthenticated), responsive structure
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task M1: Implement layout shell** - `7f91301` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
- `frontend/src/lib/components/Header.svelte` - Persistent header with clock, level, blinds, player count
|
||||
- `frontend/src/lib/components/BottomTabs.svelte` - Mobile bottom tab bar (5 tabs)
|
||||
- `frontend/src/lib/components/Sidebar.svelte` - Desktop sidebar navigation
|
||||
- `frontend/src/lib/components/FAB.svelte` - Floating action button with expandable actions
|
||||
- `frontend/src/lib/components/Toast.svelte` - Toast notification renderer
|
||||
- `frontend/src/lib/components/DataTable.svelte` - Generic sortable/searchable data table
|
||||
- `frontend/src/lib/components/TournamentTabs.svelte` - Multi-tournament tab selector
|
||||
- `frontend/src/lib/components/Loading.svelte` - Spinner, skeleton, full-page loading
|
||||
- `frontend/src/lib/stores/toast.svelte.ts` - Toast state store (Svelte 5 runes)
|
||||
- `frontend/src/lib/stores/multi-tournament.svelte.ts` - Multi-tournament routing state
|
||||
- `frontend/src/routes/+layout.svelte` - Root layout with auth guard and full shell
|
||||
- `frontend/src/routes/+page.svelte` - Redirect to /overview
|
||||
- `frontend/src/routes/overview/+page.svelte` - Overview page with stats grid
|
||||
- `frontend/src/routes/players/+page.svelte` - Players page with DataTable
|
||||
- `frontend/src/routes/tables/+page.svelte` - Tables page with DataTable
|
||||
- `frontend/src/routes/financials/+page.svelte` - Financials page with finance cards
|
||||
- `frontend/src/routes/more/+page.svelte` - Settings/logout page
|
||||
|
||||
## Decisions Made
|
||||
- Used `div` with `role="tablist"` instead of `nav` element for bottom tabs to avoid Svelte a11y warning about non-interactive elements with interactive roles
|
||||
- FAB actions dispatched via callback prop (`onaction`) to parent layout for centralized action routing (placeholder handlers until Plan N)
|
||||
- Multi-tournament state kept as a separate store from the singleton tournament state for clean separation of concerns
|
||||
- DataTable uses `Record<string, unknown>` with column render functions rather than TypeScript generics due to Svelte component prop constraints
|
||||
- Root page (`/`) redirects to `/overview` with `replaceState` to avoid back-button issues
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Fixed Svelte 5 event modifier syntax in DataTable**
|
||||
- **Found during:** Task M1 (build verification)
|
||||
- **Issue:** Used Svelte 4 syntax `onclick|stopPropagation` which is invalid in Svelte 5
|
||||
- **Fix:** Changed to `onclick={(e) => { e.stopPropagation(); ... }}` inline handler
|
||||
- **Files modified:** frontend/src/lib/components/DataTable.svelte
|
||||
- **Verification:** Build succeeds
|
||||
- **Committed in:** 7f91301
|
||||
|
||||
**2. [Rule 1 - Bug] Fixed a11y violation: nav with role=tablist**
|
||||
- **Found during:** Task M1 (build verification)
|
||||
- **Issue:** Svelte a11y check rejects interactive role on non-interactive `nav` element
|
||||
- **Fix:** Changed `nav` to `div` with `role="tablist"` for BottomTabs
|
||||
- **Files modified:** frontend/src/lib/components/BottomTabs.svelte
|
||||
- **Verification:** Build succeeds without a11y warnings
|
||||
- **Committed in:** 7f91301
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (2 bugs)
|
||||
**Impact on plan:** Both fixes necessary for build to succeed. No scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
None beyond the auto-fixed build errors above.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Layout shell complete and ready for Plan N (Feature Views: Overview, Players, Tables, Financials)
|
||||
- All navigation routes exist with placeholder content
|
||||
- DataTable component ready for use in player lists, table views, payout tables
|
||||
- Toast system ready for action feedback (bust confirmation, rebuy success, etc.)
|
||||
- FAB wired with placeholder handlers ready for Plan N to implement actual flows
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 17 key files verified present. Commit 7f91301 verified in git log.
|
||||
|
||||
---
|
||||
*Phase: 01-tournament-engine*
|
||||
*Completed: 2026-03-01*
|
||||
|
|
@ -1 +1,526 @@
|
|||
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
|
||||
}
|
||||
|
|
|
|||
329
internal/financial/payout_test.go
Normal file
329
internal/financial/payout_test.go
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
package financial
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/felt-app/felt/internal/template"
|
||||
)
|
||||
|
||||
// TestCalculatePayoutsFromPool_Basic tests basic payout calculation.
|
||||
func TestCalculatePayoutsFromPool_Basic(t *testing.T) {
|
||||
tiers := []template.PayoutTier{
|
||||
{Position: 1, PercentageBasisPoints: 5000}, // 50%
|
||||
{Position: 2, PercentageBasisPoints: 3000}, // 30%
|
||||
{Position: 3, PercentageBasisPoints: 2000}, // 20%
|
||||
}
|
||||
|
||||
payouts, err := CalculatePayoutsFromPool(100000, tiers, 100) // 1000.00 pool, round to 1.00
|
||||
if err != nil {
|
||||
t.Fatalf("calculate payouts: %v", err)
|
||||
}
|
||||
|
||||
if len(payouts) != 3 {
|
||||
t.Fatalf("expected 3 payouts, got %d", len(payouts))
|
||||
}
|
||||
|
||||
// Sum must equal prize pool
|
||||
var sum int64
|
||||
for _, p := range payouts {
|
||||
sum += p.Amount
|
||||
}
|
||||
if sum != 100000 {
|
||||
t.Errorf("sum mismatch: got %d, expected 100000", sum)
|
||||
}
|
||||
|
||||
// Verify individual amounts
|
||||
// 50% of 100000 = 50000, 30% = 30000, 20% = 20000
|
||||
if payouts[0].Amount != 50000 {
|
||||
t.Errorf("1st place: expected 50000, got %d", payouts[0].Amount)
|
||||
}
|
||||
if payouts[1].Amount != 30000 {
|
||||
t.Errorf("2nd place: expected 30000, got %d", payouts[1].Amount)
|
||||
}
|
||||
if payouts[2].Amount != 20000 {
|
||||
t.Errorf("3rd place: expected 20000, got %d", payouts[2].Amount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculatePayoutsFromPool_RoundingRemainder tests that rounding remainder goes to 1st.
|
||||
func TestCalculatePayoutsFromPool_RoundingRemainder(t *testing.T) {
|
||||
tiers := []template.PayoutTier{
|
||||
{Position: 1, PercentageBasisPoints: 5000}, // 50%
|
||||
{Position: 2, PercentageBasisPoints: 3000}, // 30%
|
||||
{Position: 3, PercentageBasisPoints: 2000}, // 20%
|
||||
}
|
||||
|
||||
// 333.33 pool with rounding to 5.00 -- this creates remainder
|
||||
payouts, err := CalculatePayoutsFromPool(33333, tiers, 500)
|
||||
if err != nil {
|
||||
t.Fatalf("calculate payouts: %v", err)
|
||||
}
|
||||
|
||||
var sum int64
|
||||
for _, p := range payouts {
|
||||
sum += p.Amount
|
||||
}
|
||||
if sum != 33333 {
|
||||
t.Errorf("sum mismatch: got %d, expected 33333", sum)
|
||||
}
|
||||
|
||||
// 50% of 33333 = 16666.5 -> floor to 16500 (round to 500)
|
||||
// 30% of 33333 = 9999.9 -> floor to 9500
|
||||
// 20% of 33333 = 6666.6 -> floor to 6500
|
||||
// Total rounded = 16500 + 9500 + 6500 = 32500
|
||||
// Remainder = 33333 - 32500 = 833 -> goes to 1st
|
||||
// 1st gets: 16500 + 833 = 17333
|
||||
if payouts[0].Amount != 17333 {
|
||||
t.Errorf("1st place: expected 17333, got %d", payouts[0].Amount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculatePayoutsFromPool_NeverRoundsUp tests that rounding never creates money.
|
||||
func TestCalculatePayoutsFromPool_NeverRoundsUp(t *testing.T) {
|
||||
tiers := []template.PayoutTier{
|
||||
{Position: 1, PercentageBasisPoints: 4000},
|
||||
{Position: 2, PercentageBasisPoints: 3000},
|
||||
{Position: 3, PercentageBasisPoints: 2000},
|
||||
{Position: 4, PercentageBasisPoints: 1000},
|
||||
}
|
||||
|
||||
denominations := []int64{100, 500, 1000, 5000}
|
||||
|
||||
for _, denom := range denominations {
|
||||
payouts, err := CalculatePayoutsFromPool(77777, tiers, denom)
|
||||
if err != nil {
|
||||
t.Fatalf("denom %d: %v", denom, err)
|
||||
}
|
||||
|
||||
var sum int64
|
||||
for _, p := range payouts {
|
||||
sum += p.Amount
|
||||
// Each payout (except 1st which gets remainder) should be at most floor(raw)
|
||||
if p.Position != 1 {
|
||||
raw := int64(77777) * p.Percentage / 10000
|
||||
if p.Amount > raw {
|
||||
t.Errorf("denom %d, pos %d: amount %d > raw %d (rounding UP detected)",
|
||||
denom, p.Position, p.Amount, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sum != 77777 {
|
||||
t.Errorf("denom %d: sum %d != 77777", denom, sum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculatePayoutsFromPool_CIGate is the CI gate test.
|
||||
// It generates 10,000+ random prize pools and verifies sum(payouts) == prizePool
|
||||
// in every case. This is the financial integrity gate.
|
||||
func TestCalculatePayoutsFromPool_CIGate(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(42)) // Deterministic for reproducibility
|
||||
|
||||
roundingDenominations := []int64{100, 500, 1000, 5000}
|
||||
|
||||
// Generate diverse payout structures (2-20 positions)
|
||||
structures := generateRandomStructures(rng, 50)
|
||||
|
||||
iterations := 0
|
||||
for _, denom := range roundingDenominations {
|
||||
for _, structure := range structures {
|
||||
for i := 0; i < 50; i++ {
|
||||
// Random prize pool: 1000 cents (10.00) to 10,000,000 cents (100,000.00)
|
||||
prizePool := int64(rng.Intn(9999000)) + 1000
|
||||
|
||||
payouts, err := CalculatePayoutsFromPool(prizePool, structure, denom)
|
||||
if err != nil {
|
||||
t.Fatalf("iteration %d: pool=%d denom=%d positions=%d: %v",
|
||||
iterations, prizePool, denom, len(structure), err)
|
||||
}
|
||||
|
||||
// CRITICAL ASSERTION: sum(payouts) == prizePool
|
||||
var sum int64
|
||||
for _, p := range payouts {
|
||||
sum += p.Amount
|
||||
// No negative payouts
|
||||
if p.Amount < 0 {
|
||||
t.Fatalf("iteration %d: negative payout: position=%d amount=%d",
|
||||
iterations, p.Position, p.Amount)
|
||||
}
|
||||
}
|
||||
|
||||
if sum != prizePool {
|
||||
t.Fatalf("CI GATE FAILURE at iteration %d: sum=%d prizePool=%d diff=%d (denom=%d, positions=%d)",
|
||||
iterations, sum, prizePool, sum-prizePool, denom, len(structure))
|
||||
}
|
||||
|
||||
iterations++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if iterations < 10000 {
|
||||
t.Fatalf("CI gate: only ran %d iterations, expected at least 10000", iterations)
|
||||
}
|
||||
t.Logf("CI gate PASSED: %d iterations, zero sum deviations", iterations)
|
||||
}
|
||||
|
||||
// TestCalculatePayoutsFromPool_GuaranteedPot tests guaranteed pot logic.
|
||||
func TestCalculatePayoutsFromPool_GuaranteedPot(t *testing.T) {
|
||||
// When pool < guarantee, house covers shortfall
|
||||
// FinalPrizePool should be max(pool, guarantee)
|
||||
tiers := []template.PayoutTier{
|
||||
{Position: 1, PercentageBasisPoints: 6000},
|
||||
{Position: 2, PercentageBasisPoints: 4000},
|
||||
}
|
||||
|
||||
// If guarantee is 50000 but pool is only 30000, house contributes 20000
|
||||
// FinalPrizePool = 50000
|
||||
payouts, err := CalculatePayoutsFromPool(50000, tiers, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("calculate payouts: %v", err)
|
||||
}
|
||||
|
||||
var sum int64
|
||||
for _, p := range payouts {
|
||||
sum += p.Amount
|
||||
}
|
||||
if sum != 50000 {
|
||||
t.Errorf("guaranteed pot: sum %d != 50000", sum)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculatePayoutsFromPool_EntryCountBracketSelection tests unique entries bracket selection.
|
||||
func TestCalculatePayoutsFromPool_EntryCountBracketSelection(t *testing.T) {
|
||||
brackets := []template.PayoutBracket{
|
||||
{MinEntries: 2, MaxEntries: 5, Tiers: []template.PayoutTier{
|
||||
{Position: 1, PercentageBasisPoints: 10000},
|
||||
}},
|
||||
{MinEntries: 6, MaxEntries: 10, Tiers: []template.PayoutTier{
|
||||
{Position: 1, PercentageBasisPoints: 6000},
|
||||
{Position: 2, PercentageBasisPoints: 4000},
|
||||
}},
|
||||
{MinEntries: 11, MaxEntries: 20, Tiers: []template.PayoutTier{
|
||||
{Position: 1, PercentageBasisPoints: 5000},
|
||||
{Position: 2, PercentageBasisPoints: 3000},
|
||||
{Position: 3, PercentageBasisPoints: 2000},
|
||||
}},
|
||||
}
|
||||
|
||||
// 3 entries -> first bracket (winner take all)
|
||||
b := selectBracket(brackets, 3)
|
||||
if b == nil {
|
||||
t.Fatal("expected bracket for 3 entries")
|
||||
}
|
||||
if len(b.Tiers) != 1 {
|
||||
t.Errorf("expected 1 tier for 3 entries, got %d", len(b.Tiers))
|
||||
}
|
||||
|
||||
// 8 entries -> second bracket
|
||||
b = selectBracket(brackets, 8)
|
||||
if b == nil {
|
||||
t.Fatal("expected bracket for 8 entries")
|
||||
}
|
||||
if len(b.Tiers) != 2 {
|
||||
t.Errorf("expected 2 tiers for 8 entries, got %d", len(b.Tiers))
|
||||
}
|
||||
|
||||
// 15 entries -> third bracket
|
||||
b = selectBracket(brackets, 15)
|
||||
if b == nil {
|
||||
t.Fatal("expected bracket for 15 entries")
|
||||
}
|
||||
if len(b.Tiers) != 3 {
|
||||
t.Errorf("expected 3 tiers for 15 entries, got %d", len(b.Tiers))
|
||||
}
|
||||
|
||||
// 25 entries -> exceeds all brackets, use last
|
||||
b = selectBracket(brackets, 25)
|
||||
if b == nil {
|
||||
t.Fatal("expected bracket for 25 entries (overflow)")
|
||||
}
|
||||
if len(b.Tiers) != 3 {
|
||||
t.Errorf("expected 3 tiers for overflow, got %d", len(b.Tiers))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedistributeForBubble tests bubble prize redistribution.
|
||||
func TestRedistributeForBubble(t *testing.T) {
|
||||
payouts := []Payout{
|
||||
{Position: 1, Amount: 50000},
|
||||
{Position: 2, Amount: 30000},
|
||||
{Position: 3, Amount: 20000},
|
||||
}
|
||||
|
||||
funding, adjusted, err := redistributeForBubble(payouts, 5000)
|
||||
if err != nil {
|
||||
t.Fatalf("redistribute: %v", err)
|
||||
}
|
||||
|
||||
// Verify funding sources exist
|
||||
if len(funding) == 0 {
|
||||
t.Fatal("expected funding sources")
|
||||
}
|
||||
|
||||
// Verify total shaved equals bubble amount
|
||||
var totalShaved int64
|
||||
for _, f := range funding {
|
||||
totalShaved += f.ReductionAmount
|
||||
}
|
||||
if totalShaved != 5000 {
|
||||
t.Errorf("expected total shaved 5000, got %d", totalShaved)
|
||||
}
|
||||
|
||||
// Verify adjusted payouts sum = original sum - bubble amount
|
||||
var originalSum, adjustedSum int64
|
||||
for _, p := range payouts {
|
||||
originalSum += p.Amount
|
||||
}
|
||||
for _, p := range adjusted {
|
||||
adjustedSum += p.Amount
|
||||
}
|
||||
if adjustedSum != originalSum-5000 {
|
||||
t.Errorf("adjusted sum %d != original %d - bubble 5000 = %d", adjustedSum, originalSum, originalSum-5000)
|
||||
}
|
||||
}
|
||||
|
||||
// generateRandomStructures creates diverse payout structures for property testing.
|
||||
func generateRandomStructures(rng *rand.Rand, count int) [][]template.PayoutTier {
|
||||
structures := make([][]template.PayoutTier, 0, count)
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
positions := rng.Intn(19) + 2 // 2-20 positions
|
||||
tiers := make([]template.PayoutTier, positions)
|
||||
|
||||
// Generate random percentages that sum to 10000
|
||||
remaining := int64(10000)
|
||||
for j := 0; j < positions; j++ {
|
||||
tiers[j].Position = j + 1
|
||||
if j == positions-1 {
|
||||
tiers[j].PercentageBasisPoints = remaining
|
||||
} else {
|
||||
// Min 1 basis point per remaining position
|
||||
minPer := int64(positions - j)
|
||||
maxPer := remaining - minPer
|
||||
if maxPer < 1 {
|
||||
maxPer = 1
|
||||
}
|
||||
// Weighted toward higher positions getting more
|
||||
pct := int64(rng.Intn(int(maxPer/int64(positions-j)))) + 1
|
||||
if pct > remaining-int64(positions-j-1) {
|
||||
pct = remaining - int64(positions-j-1)
|
||||
}
|
||||
tiers[j].PercentageBasisPoints = pct
|
||||
remaining -= pct
|
||||
}
|
||||
}
|
||||
|
||||
// Verify sum
|
||||
var sum int64
|
||||
for _, tier := range tiers {
|
||||
sum += tier.PercentageBasisPoints
|
||||
}
|
||||
if sum == 10000 {
|
||||
structures = append(structures, tiers)
|
||||
}
|
||||
}
|
||||
|
||||
return structures
|
||||
}
|
||||
|
|
@ -1 +1,200 @@
|
|||
package financial
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Receipt represents a financial transaction receipt.
|
||||
type Receipt struct {
|
||||
ID string `json:"id"` // Same as transaction ID
|
||||
TournamentID string `json:"tournament_id"`
|
||||
TournamentName string `json:"tournament_name"`
|
||||
VenueName string `json:"venue_name"`
|
||||
CurrencyCode string `json:"currency_code"`
|
||||
CurrencySymbol string `json:"currency_symbol"`
|
||||
PlayerID string `json:"player_id"`
|
||||
PlayerName string `json:"player_name"`
|
||||
TransactionType string `json:"transaction_type"`
|
||||
Amount int64 `json:"amount"` // cents
|
||||
ChipsReceived int64 `json:"chips_received"`
|
||||
ReceiptNumber int `json:"receipt_number"` // Sequential per tournament
|
||||
Timestamp int64 `json:"timestamp"` // Unix seconds
|
||||
OperatorID string `json:"operator_id"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
|
||||
// Running totals for this player in this tournament
|
||||
PlayerTotalBuyIns int `json:"player_total_buyins"`
|
||||
PlayerTotalRebuys int `json:"player_total_rebuys"`
|
||||
PlayerTotalAddOns int `json:"player_total_addons"`
|
||||
|
||||
// Reprint tracking
|
||||
OriginalTimestamp int64 `json:"original_timestamp,omitempty"` // For reprints
|
||||
IsReprint bool `json:"is_reprint"`
|
||||
}
|
||||
|
||||
// GenerateReceipt creates a receipt for a given transaction.
|
||||
func (e *Engine) GenerateReceipt(ctx context.Context, transaction *Transaction) (*Receipt, error) {
|
||||
// Get tournament name
|
||||
var tournamentName string
|
||||
err := e.db.QueryRowContext(ctx,
|
||||
"SELECT name FROM tournaments WHERE id = ?",
|
||||
transaction.TournamentID,
|
||||
).Scan(&tournamentName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("financial: load tournament name: %w", err)
|
||||
}
|
||||
|
||||
// Get venue info
|
||||
venueName, currencyCode, currencySymbol := e.loadVenueInfo(ctx)
|
||||
|
||||
// Get player name
|
||||
var playerName string
|
||||
err = e.db.QueryRowContext(ctx,
|
||||
"SELECT name FROM players WHERE id = ?",
|
||||
transaction.PlayerID,
|
||||
).Scan(&playerName)
|
||||
if err != nil {
|
||||
playerName = "Unknown" // Defensive
|
||||
}
|
||||
|
||||
// Get operator name
|
||||
var operatorName string
|
||||
err = e.db.QueryRowContext(ctx,
|
||||
"SELECT name FROM operators WHERE id = ?",
|
||||
transaction.OperatorID,
|
||||
).Scan(&operatorName)
|
||||
if err != nil {
|
||||
operatorName = transaction.OperatorID // Fallback to ID
|
||||
}
|
||||
|
||||
// Calculate receipt number (sequential within tournament)
|
||||
receiptNumber, err := e.nextReceiptNumber(ctx, transaction.TournamentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get player running totals
|
||||
buyins, rebuys, addons := e.playerRunningTotals(ctx, transaction.TournamentID, transaction.PlayerID)
|
||||
|
||||
receipt := &Receipt{
|
||||
ID: transaction.ID,
|
||||
TournamentID: transaction.TournamentID,
|
||||
TournamentName: tournamentName,
|
||||
VenueName: venueName,
|
||||
CurrencyCode: currencyCode,
|
||||
CurrencySymbol: currencySymbol,
|
||||
PlayerID: transaction.PlayerID,
|
||||
PlayerName: playerName,
|
||||
TransactionType: transaction.Type,
|
||||
Amount: transaction.Amount,
|
||||
ChipsReceived: transaction.Chips,
|
||||
ReceiptNumber: receiptNumber,
|
||||
Timestamp: time.Now().Unix(),
|
||||
OperatorID: transaction.OperatorID,
|
||||
OperatorName: operatorName,
|
||||
PlayerTotalBuyIns: buyins,
|
||||
PlayerTotalRebuys: rebuys,
|
||||
PlayerTotalAddOns: addons,
|
||||
}
|
||||
|
||||
// Store receipt data on the transaction
|
||||
receiptJSON, err := json.Marshal(receipt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("financial: marshal receipt: %w", err)
|
||||
}
|
||||
|
||||
_, err = e.db.ExecContext(ctx,
|
||||
"UPDATE transactions SET receipt_data = ? WHERE id = ?",
|
||||
string(receiptJSON), transaction.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("financial: save receipt: %w", err)
|
||||
}
|
||||
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
// GetReceipt retrieves a saved receipt for a transaction.
|
||||
func (e *Engine) GetReceipt(ctx context.Context, transactionID string) (*Receipt, error) {
|
||||
var receiptData sql.NullString
|
||||
|
||||
err := e.db.QueryRowContext(ctx,
|
||||
"SELECT receipt_data FROM transactions WHERE id = ?",
|
||||
transactionID,
|
||||
).Scan(&receiptData)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTransactionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("financial: load receipt: %w", err)
|
||||
}
|
||||
|
||||
if !receiptData.Valid || receiptData.String == "" {
|
||||
return nil, fmt.Errorf("financial: no receipt for transaction %s", transactionID)
|
||||
}
|
||||
|
||||
var receipt Receipt
|
||||
if err := json.Unmarshal([]byte(receiptData.String), &receipt); err != nil {
|
||||
return nil, fmt.Errorf("financial: parse receipt: %w", err)
|
||||
}
|
||||
|
||||
return &receipt, nil
|
||||
}
|
||||
|
||||
// ReprintReceipt returns the same receipt data with a new print timestamp.
|
||||
func (e *Engine) ReprintReceipt(ctx context.Context, transactionID string) (*Receipt, error) {
|
||||
receipt, err := e.GetReceipt(ctx, transactionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Mark as reprint with new timestamp
|
||||
receipt.OriginalTimestamp = receipt.Timestamp
|
||||
receipt.Timestamp = time.Now().Unix()
|
||||
receipt.IsReprint = true
|
||||
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
// loadVenueInfo loads the venue settings.
|
||||
func (e *Engine) loadVenueInfo(ctx context.Context) (name, currencyCode, currencySymbol string) {
|
||||
err := e.db.QueryRowContext(ctx,
|
||||
"SELECT venue_name, currency_code, currency_symbol FROM venue_settings WHERE id = 1",
|
||||
).Scan(&name, ¤cyCode, ¤cySymbol)
|
||||
if err != nil {
|
||||
return "Venue", "DKK", "kr" // Defaults
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// nextReceiptNumber returns the next sequential receipt number for a tournament.
|
||||
func (e *Engine) nextReceiptNumber(ctx context.Context, tournamentID string) (int, error) {
|
||||
var count int
|
||||
err := e.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM transactions
|
||||
WHERE tournament_id = ? AND receipt_data IS NOT NULL`,
|
||||
tournamentID,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("financial: count receipts: %w", err)
|
||||
}
|
||||
return count + 1, nil
|
||||
}
|
||||
|
||||
// playerRunningTotals returns the player's current transaction counts.
|
||||
func (e *Engine) playerRunningTotals(ctx context.Context, tournamentID, playerID string) (buyins, rebuys, addons int) {
|
||||
e.db.QueryRowContext(ctx,
|
||||
`SELECT
|
||||
COALESCE(SUM(CASE WHEN type = 'buyin' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN type = 'rebuy' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN type = 'addon' THEN 1 ELSE 0 END), 0)
|
||||
FROM transactions
|
||||
WHERE tournament_id = ? AND player_id = ? AND undone = 0`,
|
||||
tournamentID, playerID,
|
||||
).Scan(&buyins, &rebuys, &addons)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
222
internal/server/routes/financials.go
Normal file
222
internal/server/routes/financials.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/felt-app/felt/internal/financial"
|
||||
"github.com/felt-app/felt/internal/server/middleware"
|
||||
)
|
||||
|
||||
// FinancialHandler handles financial API routes.
|
||||
type FinancialHandler struct {
|
||||
engine *financial.Engine
|
||||
}
|
||||
|
||||
// NewFinancialHandler creates a new financial route handler.
|
||||
func NewFinancialHandler(engine *financial.Engine) *FinancialHandler {
|
||||
return &FinancialHandler{engine: engine}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers financial routes on the given router.
|
||||
func (h *FinancialHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Route("/tournaments/{id}", func(r chi.Router) {
|
||||
// Read-only routes (any authenticated user)
|
||||
r.Get("/prize-pool", h.handleGetPrizePool)
|
||||
r.Get("/payouts", h.handleGetPayouts)
|
||||
r.Get("/payouts/preview", h.handlePreviewPayouts)
|
||||
r.Get("/transactions", h.handleGetTransactions)
|
||||
r.Get("/transactions/{txId}/receipt", h.handleGetReceipt)
|
||||
|
||||
// Mutation routes (admin or floor)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireRole(middleware.RoleFloor))
|
||||
r.Post("/bubble-prize", h.handleCreateBubblePrize)
|
||||
r.Post("/bubble-prize/{proposalId}/confirm", h.handleConfirmBubblePrize)
|
||||
r.Post("/transactions/{txId}/reprint", h.handleReprintReceipt)
|
||||
})
|
||||
})
|
||||
|
||||
// Season reserves (venue-level, any authenticated user)
|
||||
r.Get("/season-reserves", h.handleGetSeasonReserves)
|
||||
}
|
||||
|
||||
// handleGetPrizePool returns the current prize pool summary for a tournament.
|
||||
func (h *FinancialHandler) handleGetPrizePool(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
if tournamentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "tournament id required")
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.engine.CalculatePrizePool(r.Context(), tournamentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// handleGetPayouts returns the calculated payout table.
|
||||
func (h *FinancialHandler) handleGetPayouts(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
if tournamentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "tournament id required")
|
||||
return
|
||||
}
|
||||
|
||||
payouts, err := h.engine.CalculatePayouts(r.Context(), tournamentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"payouts": payouts,
|
||||
})
|
||||
}
|
||||
|
||||
// handlePreviewPayouts returns a preview of payouts without applying them.
|
||||
func (h *FinancialHandler) handlePreviewPayouts(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
if tournamentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "tournament id required")
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.engine.CalculatePrizePool(r.Context(), tournamentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
payouts, err := h.engine.CalculatePayouts(r.Context(), tournamentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"prize_pool": summary,
|
||||
"payouts": payouts,
|
||||
"preview": true,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetTransactions returns all transactions for a tournament.
|
||||
func (h *FinancialHandler) handleGetTransactions(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
if tournamentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "tournament id required")
|
||||
return
|
||||
}
|
||||
|
||||
txs, err := h.engine.GetTransactions(r.Context(), tournamentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, txs)
|
||||
}
|
||||
|
||||
// handleGetReceipt returns the receipt for a transaction.
|
||||
func (h *FinancialHandler) handleGetReceipt(w http.ResponseWriter, r *http.Request) {
|
||||
txID := chi.URLParam(r, "txId")
|
||||
if txID == "" {
|
||||
writeError(w, http.StatusBadRequest, "transaction id required")
|
||||
return
|
||||
}
|
||||
|
||||
receipt, err := h.engine.GetReceipt(r.Context(), txID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, receipt)
|
||||
}
|
||||
|
||||
// handleReprintReceipt returns the receipt with a new print timestamp.
|
||||
func (h *FinancialHandler) handleReprintReceipt(w http.ResponseWriter, r *http.Request) {
|
||||
txID := chi.URLParam(r, "txId")
|
||||
if txID == "" {
|
||||
writeError(w, http.StatusBadRequest, "transaction id required")
|
||||
return
|
||||
}
|
||||
|
||||
receipt, err := h.engine.ReprintReceipt(r.Context(), txID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, receipt)
|
||||
}
|
||||
|
||||
type bubblePrizeRequest struct {
|
||||
Amount int64 `json:"amount"` // cents
|
||||
}
|
||||
|
||||
// handleCreateBubblePrize creates a bubble prize proposal.
|
||||
func (h *FinancialHandler) handleCreateBubblePrize(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
if tournamentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "tournament id required")
|
||||
return
|
||||
}
|
||||
|
||||
var req bubblePrizeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Amount <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "amount must be positive")
|
||||
return
|
||||
}
|
||||
|
||||
proposal, err := h.engine.CalculateBubblePrize(r.Context(), tournamentID, req.Amount)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, proposal)
|
||||
}
|
||||
|
||||
// handleConfirmBubblePrize confirms a bubble prize proposal.
|
||||
func (h *FinancialHandler) handleConfirmBubblePrize(w http.ResponseWriter, r *http.Request) {
|
||||
tournamentID := chi.URLParam(r, "id")
|
||||
proposalIDStr := chi.URLParam(r, "proposalId")
|
||||
|
||||
proposalID, err := strconv.ParseInt(proposalIDStr, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid proposal id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.engine.ConfirmBubblePrize(r.Context(), tournamentID, proposalID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "confirmed"})
|
||||
}
|
||||
|
||||
// handleGetSeasonReserves returns the season reserve summary.
|
||||
func (h *FinancialHandler) handleGetSeasonReserves(w http.ResponseWriter, r *http.Request) {
|
||||
reserves, err := h.engine.GetSeasonReserves(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"reserves": reserves,
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue