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
|
### Operator UI
|
||||||
|
|
||||||
- [ ] **UI-01**: Mobile-first bottom tab bar (Overview, Players, Tables, Financials, More)
|
- [x] **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)
|
- [x] **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
|
- [x] **UI-03**: Persistent header showing clock, level, blinds, player count
|
||||||
- [ ] **UI-04**: Desktop/laptop sidebar navigation with wider content area
|
- [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-05**: Catppuccin Mocha dark theme (default) and Latte light theme
|
||||||
- [x] **UI-06**: 48px minimum touch targets, press-state animations, loading states
|
- [x] **UI-06**: 48px minimum touch targets, press-state animations, loading states
|
||||||
- [ ] **UI-07**: Toast notifications (success, info, warning, error) with auto-dismiss
|
- [x] **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-08**: Data tables with sort, sticky header, search/filter, swipe actions (mobile)
|
||||||
|
|
||||||
## v2 Requirements
|
## v2 Requirements
|
||||||
|
|
||||||
|
|
@ -365,14 +365,14 @@ Which phases cover which requirements. Updated during roadmap reorganization.
|
||||||
| SEAT-07 | Phase 1 | Pending |
|
| SEAT-07 | Phase 1 | Pending |
|
||||||
| SEAT-08 | Phase 1 | Pending |
|
| SEAT-08 | Phase 1 | Pending |
|
||||||
| SEAT-09 | Phase 1 | Pending |
|
| SEAT-09 | Phase 1 | Pending |
|
||||||
| UI-01 | Phase 1 | Pending |
|
| UI-01 | Phase 1 | Complete |
|
||||||
| UI-02 | Phase 1 | Pending |
|
| UI-02 | Phase 1 | Complete |
|
||||||
| UI-03 | Phase 1 | Pending |
|
| UI-03 | Phase 1 | Complete |
|
||||||
| UI-04 | Phase 1 | Pending |
|
| UI-04 | Phase 1 | Complete |
|
||||||
| UI-05 | Phase 1 | Complete |
|
| UI-05 | Phase 1 | Complete |
|
||||||
| UI-06 | Phase 1 | Complete |
|
| UI-06 | Phase 1 | Complete |
|
||||||
| UI-07 | Phase 1 | Pending |
|
| UI-07 | Phase 1 | Complete |
|
||||||
| UI-08 | Phase 1 | Pending |
|
| UI-08 | Phase 1 | Complete |
|
||||||
| DISP-01 | Phase 2 | Pending |
|
| DISP-01 | Phase 2 | Pending |
|
||||||
| DISP-02 | Phase 2 | Pending |
|
| DISP-02 | Phase 2 | Pending |
|
||||||
| DISP-03 | Phase 2 | Pending |
|
| DISP-03 | Phase 2 | Pending |
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ progress:
|
||||||
total_phases: 1
|
total_phases: 1
|
||||||
completed_phases: 0
|
completed_phases: 0
|
||||||
total_plans: 14
|
total_plans: 14
|
||||||
completed_plans: 6
|
completed_plans: 7
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
|
|
@ -23,27 +23,27 @@ See: .planning/PROJECT.md (updated 2026-02-28)
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 1 of 7 (Tournament Engine)
|
Phase: 1 of 7 (Tournament Engine)
|
||||||
Plan: 7 of 14 in current phase
|
Plan: 8 of 14 in current phase
|
||||||
Status: Executing Phase 1
|
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
|
## Performance Metrics
|
||||||
|
|
||||||
**Velocity:**
|
**Velocity:**
|
||||||
- Total plans completed: 6
|
- Total plans completed: 7
|
||||||
- Average duration: 9min
|
- Average duration: 8min
|
||||||
- Total execution time: 0.88 hours
|
- Total execution time: 0.97 hours
|
||||||
|
|
||||||
**By Phase:**
|
**By Phase:**
|
||||||
|
|
||||||
| Phase | Plans | Total | Avg/Plan |
|
| Phase | Plans | Total | Avg/Plan |
|
||||||
|-------|-------|-------|----------|
|
|-------|-------|-------|----------|
|
||||||
| 01-tournament-engine | 6 | 53min | 9min |
|
| 01-tournament-engine | 7 | 58min | 8min |
|
||||||
|
|
||||||
**Recent Trend:**
|
**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
|
- Trend: accelerating
|
||||||
|
|
||||||
*Updated after each plan completion*
|
*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]: 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]: 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-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
|
### Pending Todos
|
||||||
|
|
||||||
|
|
@ -99,5 +103,5 @@ None yet.
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-01
|
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
|
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
|
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
|
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