lottery
This commit is contained in:
parent
ef105e1cac
commit
b918b92a4d
6 changed files with 702 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -77,6 +78,31 @@ type LeaderboardEntry struct {
|
||||||
BalanceMinor int64
|
BalanceMinor int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LotteryRoundView struct {
|
||||||
|
ID string
|
||||||
|
PoolMinor int64
|
||||||
|
DrawAt time.Time
|
||||||
|
ParticipantCount int
|
||||||
|
TicketCount int
|
||||||
|
UserTicketCount int
|
||||||
|
UserContributionMinor int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type LotteryPayoutView struct {
|
||||||
|
RoundID string
|
||||||
|
WinnerUsername string
|
||||||
|
WinnerAccount string
|
||||||
|
PoolMinor int64
|
||||||
|
ParticipantCount int
|
||||||
|
TicketCount int
|
||||||
|
CompletedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type LotteryDashboard struct {
|
||||||
|
ActiveRound *LotteryRoundView
|
||||||
|
RecentPayouts []LotteryPayoutView
|
||||||
|
}
|
||||||
|
|
||||||
type AuditLog struct {
|
type AuditLog struct {
|
||||||
Action string
|
Action string
|
||||||
TargetType string
|
TargetType string
|
||||||
|
|
@ -95,6 +121,21 @@ type AdminDashboard struct {
|
||||||
RecentAuditActivity []AuditLog
|
RecentAuditActivity []AuditLog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type lotteryEntryRecord struct {
|
||||||
|
UserID string
|
||||||
|
AccountID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type lotterySettlementResult struct {
|
||||||
|
RoundID string
|
||||||
|
WinnerUsername string
|
||||||
|
WinnerAccount string
|
||||||
|
PoolMinor int64
|
||||||
|
ParticipantCount int
|
||||||
|
TicketCount int
|
||||||
|
CompletedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store {
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
return &Store{pool: pool}
|
return &Store{pool: pool}
|
||||||
}
|
}
|
||||||
|
|
@ -536,6 +577,210 @@ func (s *Store) CreateTransfer(ctx context.Context, senderUserID, destinationAcc
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetLotteryDashboard(ctx context.Context, userID string) (*LotteryDashboard, error) {
|
||||||
|
var dashboard LotteryDashboard
|
||||||
|
|
||||||
|
var active LotteryRoundView
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
select
|
||||||
|
lr.id,
|
||||||
|
lr.pool_minor,
|
||||||
|
lr.draw_at,
|
||||||
|
coalesce((select count(distinct le.user_id) from lottery_entries le where le.round_id = lr.id), 0),
|
||||||
|
coalesce((select count(*) from lottery_entries le where le.round_id = lr.id), 0),
|
||||||
|
coalesce((select count(*) from lottery_entries le where le.round_id = lr.id and le.user_id = $1), 0),
|
||||||
|
coalesce((select sum(le.amount_minor) from lottery_entries le where le.round_id = lr.id and le.user_id = $1), 0)
|
||||||
|
from lottery_rounds lr
|
||||||
|
where lr.status = 'active'
|
||||||
|
order by lr.created_at desc
|
||||||
|
limit 1
|
||||||
|
`, userID).Scan(
|
||||||
|
&active.ID,
|
||||||
|
&active.PoolMinor,
|
||||||
|
&active.DrawAt,
|
||||||
|
&active.ParticipantCount,
|
||||||
|
&active.TicketCount,
|
||||||
|
&active.UserTicketCount,
|
||||||
|
&active.UserContributionMinor,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
dashboard.ActiveRound = &active
|
||||||
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("load active lottery round: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
select
|
||||||
|
lr.id,
|
||||||
|
u.username,
|
||||||
|
a.account_number,
|
||||||
|
lr.pool_minor,
|
||||||
|
coalesce((select count(distinct le.user_id) from lottery_entries le where le.round_id = lr.id), 0),
|
||||||
|
coalesce((select count(*) from lottery_entries le where le.round_id = lr.id), 0),
|
||||||
|
lr.completed_at
|
||||||
|
from lottery_rounds lr
|
||||||
|
join users u on u.id = lr.winner_user_id
|
||||||
|
join accounts a on a.id = lr.winner_account_id
|
||||||
|
where lr.status = 'completed' and lr.winner_user_id is not null
|
||||||
|
order by lr.completed_at desc
|
||||||
|
limit 8
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list lottery payouts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var item LotteryPayoutView
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.RoundID,
|
||||||
|
&item.WinnerUsername,
|
||||||
|
&item.WinnerAccount,
|
||||||
|
&item.PoolMinor,
|
||||||
|
&item.ParticipantCount,
|
||||||
|
&item.TicketCount,
|
||||||
|
&item.CompletedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan lottery payout: %w", err)
|
||||||
|
}
|
||||||
|
dashboard.RecentPayouts = append(dashboard.RecentPayouts, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dashboard, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateLotteryDeposit(ctx context.Context, userID string, amountMinor int64) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin lottery deposit: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if _, err := s.settleDueLotteryRoundTx(ctx, tx, time.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountRecord struct {
|
||||||
|
AccountID string
|
||||||
|
UserStatus string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
var account accountRecord
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
select a.id, u.status, a.status
|
||||||
|
from accounts a
|
||||||
|
join users u on u.id = a.user_id
|
||||||
|
where u.id = $1
|
||||||
|
for update
|
||||||
|
`, userID).Scan(&account.AccountID, &account.UserStatus, &account.Status)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load lottery depositor account: %w", err)
|
||||||
|
}
|
||||||
|
if account.UserStatus != "active" || account.Status != "open" {
|
||||||
|
return ErrFrozen
|
||||||
|
}
|
||||||
|
|
||||||
|
balance, err := balanceForAccountTx(ctx, tx, account.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if balance < amountMinor {
|
||||||
|
return ErrInsufficientFunds
|
||||||
|
}
|
||||||
|
|
||||||
|
roundID, drawAt, err := s.findOrCreateActiveLotteryRoundTx(ctx, tx, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionID, err := platform.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reference, err := platform.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ledgerEntryID, err := platform.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lotteryEntryID, err := platform.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
description := fmt.Sprintf("Lottery deposit for round closing at %s", drawAt.Format("15:04"))
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
insert into transactions (id, type, status, reference, initiated_by_user_id, description, created_at)
|
||||||
|
values ($1, 'lottery_deposit', 'completed', $2, $3, $4, now())
|
||||||
|
`, transactionID, reference, userID, description)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert lottery deposit transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
insert into ledger_entries (id, transaction_id, account_id, entry_type, amount_minor, created_at)
|
||||||
|
values ($1, $2, $3, 'debit', $4, now())
|
||||||
|
`, ledgerEntryID, transactionID, account.AccountID, amountMinor)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert lottery deposit ledger entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
insert into lottery_entries (id, round_id, user_id, account_id, amount_minor, created_at)
|
||||||
|
values ($1, $2, $3, $4, $5, now())
|
||||||
|
`, lotteryEntryID, roundID, userID, account.AccountID, amountMinor)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert lottery entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
update lottery_rounds
|
||||||
|
set pool_minor = pool_minor + $2
|
||||||
|
where id = $1
|
||||||
|
`, roundID, amountMinor)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update lottery round pool: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit lottery deposit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SettleDueLotteryRound(ctx context.Context) (*LotteryPayoutView, error) {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin lottery settlement: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
result, err := s.settleDueLotteryRoundTx(ctx, tx, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit lottery settlement: %w", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LotteryPayoutView{
|
||||||
|
RoundID: result.RoundID,
|
||||||
|
WinnerUsername: result.WinnerUsername,
|
||||||
|
WinnerAccount: result.WinnerAccount,
|
||||||
|
PoolMinor: result.PoolMinor,
|
||||||
|
ParticipantCount: result.ParticipantCount,
|
||||||
|
TicketCount: result.TicketCount,
|
||||||
|
CompletedAt: result.CompletedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) AdminDashboard(ctx context.Context) (*AdminDashboard, error) {
|
func (s *Store) AdminDashboard(ctx context.Context) (*AdminDashboard, error) {
|
||||||
var dashboard AdminDashboard
|
var dashboard AdminDashboard
|
||||||
if err := s.pool.QueryRow(ctx, `select count(*) from users`).Scan(&dashboard.UserCount); err != nil {
|
if err := s.pool.QueryRow(ctx, `select count(*) from users`).Scan(&dashboard.UserCount); err != nil {
|
||||||
|
|
@ -821,6 +1066,171 @@ func (s *Store) insertAuditLog(ctx context.Context, adminID, action, targetType,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) findOrCreateActiveLotteryRoundTx(ctx context.Context, tx pgx.Tx, now time.Time) (string, time.Time, error) {
|
||||||
|
var roundID string
|
||||||
|
var drawAt time.Time
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
select id, draw_at
|
||||||
|
from lottery_rounds
|
||||||
|
where status = 'active'
|
||||||
|
order by created_at desc
|
||||||
|
limit 1
|
||||||
|
for update
|
||||||
|
`).Scan(&roundID, &drawAt)
|
||||||
|
if err == nil {
|
||||||
|
return roundID, drawAt, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", time.Time{}, fmt.Errorf("load active lottery round: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roundID, err = platform.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
drawAt = now.Add(time.Hour)
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
insert into lottery_rounds (id, status, pool_minor, draw_at, created_at)
|
||||||
|
values ($1, 'active', 0, $2, $3)
|
||||||
|
`, roundID, drawAt, now)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, fmt.Errorf("create lottery round: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return roundID, drawAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) settleDueLotteryRoundTx(ctx context.Context, tx pgx.Tx, now time.Time) (*lotterySettlementResult, error) {
|
||||||
|
var round struct {
|
||||||
|
ID string
|
||||||
|
PoolMinor int64
|
||||||
|
DrawAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
select id, pool_minor, draw_at
|
||||||
|
from lottery_rounds
|
||||||
|
where status = 'active'
|
||||||
|
order by created_at asc
|
||||||
|
limit 1
|
||||||
|
for update
|
||||||
|
`).Scan(&round.ID, &round.PoolMinor, &round.DrawAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load active lottery round for settlement: %w", err)
|
||||||
|
}
|
||||||
|
if round.DrawAt.After(now) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
select user_id, account_id
|
||||||
|
from lottery_entries
|
||||||
|
where round_id = $1
|
||||||
|
order by created_at asc, id asc
|
||||||
|
`, round.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load lottery entries: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []lotteryEntryRecord
|
||||||
|
participants := map[string]struct{}{}
|
||||||
|
for rows.Next() {
|
||||||
|
var entry lotteryEntryRecord
|
||||||
|
if err := rows.Scan(&entry.UserID, &entry.AccountID); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan lottery entry: %w", err)
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
participants[entry.UserID] = struct{}{}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 || round.PoolMinor == 0 {
|
||||||
|
_, err := tx.Exec(ctx, `
|
||||||
|
update lottery_rounds
|
||||||
|
set status = 'completed', completed_at = $2
|
||||||
|
where id = $1
|
||||||
|
`, round.ID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("complete empty lottery round: %w", err)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
winnerIndex, err := cryptoRandomIndex(len(entries))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
winner := entries[winnerIndex]
|
||||||
|
|
||||||
|
var winnerUsername, winnerAccount string
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
select u.username, a.account_number
|
||||||
|
from users u
|
||||||
|
join accounts a on a.id = $1
|
||||||
|
where u.id = $2
|
||||||
|
`, winner.AccountID, winner.UserID).Scan(&winnerUsername, &winnerAccount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load lottery winner: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionID, err := platform.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reference, err := platform.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ledgerEntryID, err := platform.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
insert into transactions (id, type, status, reference, initiated_by_user_id, description, created_at)
|
||||||
|
values ($1, 'lottery_payout', 'completed', $2, $3, $4, $5)
|
||||||
|
`, transactionID, reference, winner.UserID, fmt.Sprintf("Lottery payout from %d ticket(s)", len(entries)), now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert lottery payout transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
insert into ledger_entries (id, transaction_id, account_id, entry_type, amount_minor, created_at)
|
||||||
|
values ($1, $2, $3, 'credit', $4, $5)
|
||||||
|
`, ledgerEntryID, transactionID, winner.AccountID, round.PoolMinor, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert lottery payout ledger entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
update lottery_rounds
|
||||||
|
set status = 'completed',
|
||||||
|
winner_user_id = $2,
|
||||||
|
winner_account_id = $3,
|
||||||
|
completed_at = $4
|
||||||
|
where id = $1
|
||||||
|
`, round.ID, winner.UserID, winner.AccountID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("complete lottery round: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &lotterySettlementResult{
|
||||||
|
RoundID: round.ID,
|
||||||
|
WinnerUsername: winnerUsername,
|
||||||
|
WinnerAccount: winnerAccount,
|
||||||
|
PoolMinor: round.PoolMinor,
|
||||||
|
ParticipantCount: len(participants),
|
||||||
|
TicketCount: len(entries),
|
||||||
|
CompletedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) generateUniqueAccountNumber(ctx context.Context, tx pgx.Tx) (string, error) {
|
func (s *Store) generateUniqueAccountNumber(ctx context.Context, tx pgx.Tx) (string, error) {
|
||||||
for i := 0; i < 20; i++ {
|
for i := 0; i < 20; i++ {
|
||||||
candidate, err := randomAccountNumber()
|
candidate, err := randomAccountNumber()
|
||||||
|
|
@ -853,6 +1263,19 @@ func randomAccountNumber() (string, error) {
|
||||||
return fmt.Sprintf("%010d", n), nil
|
return fmt.Sprintf("%010d", n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cryptoRandomIndex(limit int) (int, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
return 0, fmt.Errorf("limit must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(limit)))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("read random index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(n.Int64()), nil
|
||||||
|
}
|
||||||
|
|
||||||
func balanceForAccountTx(ctx context.Context, tx pgx.Tx, accountID string) (int64, error) {
|
func balanceForAccountTx(ctx context.Context, tx pgx.Tx, accountID string) (int64, error) {
|
||||||
var balance int64
|
var balance int64
|
||||||
err := tx.QueryRow(ctx, `
|
err := tx.QueryRow(ctx, `
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,12 @@ type DashboardData struct {
|
||||||
Transactions []db.TransactionView
|
Transactions []db.TransactionView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LotteryPageData struct {
|
||||||
|
User *db.User
|
||||||
|
Lottery *db.LotteryDashboard
|
||||||
|
LastPayout *db.LotteryPayoutView
|
||||||
|
}
|
||||||
|
|
||||||
type AdminDashboardData struct {
|
type AdminDashboardData struct {
|
||||||
Admin *db.AdminUser
|
Admin *db.AdminUser
|
||||||
Dashboard *db.AdminDashboard
|
Dashboard *db.AdminDashboard
|
||||||
|
|
@ -158,6 +164,8 @@ func (a *App) routes() http.Handler {
|
||||||
protected.Get("/app", a.userDashboard)
|
protected.Get("/app", a.userDashboard)
|
||||||
protected.Get("/app/transfer", a.showTransfer)
|
protected.Get("/app/transfer", a.showTransfer)
|
||||||
protected.Post("/app/transfer", a.createTransfer)
|
protected.Post("/app/transfer", a.createTransfer)
|
||||||
|
protected.Get("/app/lottery", a.lotteryPage)
|
||||||
|
protected.Post("/app/lottery/deposit", a.createLotteryDeposit)
|
||||||
protected.Get("/app/transactions", a.transactionsPage)
|
protected.Get("/app/transactions", a.transactionsPage)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -387,6 +395,94 @@ func (a *App) transactionsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
a.render(w, http.StatusOK, ViewData{Title: "Transactions", Page: "transactions", User: user, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r), Data: DashboardData{User: user, Transactions: transactions}})
|
a.render(w, http.StatusOK, ViewData{Title: "Transactions", Page: "transactions", User: user, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r), Data: DashboardData{User: user, Transactions: transactions}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) lotteryPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _ := a.currentUser(r)
|
||||||
|
lastPayout, err := a.store.SettleDueLotteryRound(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedUser, err := a.store.GetUserDashboard(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lottery, err := a.store.GetLotteryDashboard(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.renderLotteryPage(w, r, http.StatusOK, updatedUser, lottery, lastPayout, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) createLotteryDeposit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _ := a.currentUser(r)
|
||||||
|
if !a.verifyCSRF(r) {
|
||||||
|
a.renderLotteryError(w, r, user, http.StatusBadRequest, "Invalid form token.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
a.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amountMinor, err := platform.ParseCZK(r.FormValue("amount"))
|
||||||
|
if err != nil || amountMinor <= 0 {
|
||||||
|
a.renderLotteryError(w, r, user, http.StatusBadRequest, "Enter a valid positive CZK amount.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.store.CreateLotteryDeposit(r.Context(), user.ID, amountMinor); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, db.ErrInsufficientFunds):
|
||||||
|
a.renderLotteryError(w, r, user, http.StatusBadRequest, "Insufficient funds for this lottery deposit.")
|
||||||
|
case errors.Is(err, db.ErrFrozen):
|
||||||
|
a.renderLotteryError(w, r, user, http.StatusForbidden, "Lottery deposits are not available while the account is frozen.")
|
||||||
|
default:
|
||||||
|
a.renderLotteryError(w, r, user, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.setFlash(w, "Lottery deposit added to the active pool.")
|
||||||
|
http.Redirect(w, r, "/app/lottery", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderLotteryPage(w http.ResponseWriter, r *http.Request, status int, user *db.User, lottery *db.LotteryDashboard, lastPayout *db.LotteryPayoutView, errMsg string) {
|
||||||
|
a.render(w, status, ViewData{
|
||||||
|
Title: "Lottery",
|
||||||
|
Page: "lottery",
|
||||||
|
User: user,
|
||||||
|
CSRFToken: a.csrfToken(r),
|
||||||
|
Flash: a.readFlash(w, r),
|
||||||
|
Error: errMsg,
|
||||||
|
Data: LotteryPageData{
|
||||||
|
User: user,
|
||||||
|
Lottery: lottery,
|
||||||
|
LastPayout: lastPayout,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderLotteryError(w http.ResponseWriter, r *http.Request, user *db.User, status int, errMsg string) {
|
||||||
|
lottery, err := a.store.GetLotteryDashboard(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedUser, err := a.store.GetUserDashboard(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.renderLotteryPage(w, r, status, updatedUser, lottery, nil, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) showAdminLogin(w http.ResponseWriter, r *http.Request) {
|
func (a *App) showAdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
a.render(w, http.StatusOK, ViewData{Title: "Admin login", Page: "admin_login", CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r)})
|
a.render(w, http.StatusOK, ViewData{Title: "Admin login", Page: "admin_login", CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r)})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
migrations/002_lottery.sql
Normal file
23
migrations/002_lottery.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
create table if not exists lottery_rounds (
|
||||||
|
id text primary key,
|
||||||
|
status text not null,
|
||||||
|
pool_minor bigint not null default 0 check (pool_minor >= 0),
|
||||||
|
winner_user_id text references users(id),
|
||||||
|
winner_account_id text references accounts(id),
|
||||||
|
draw_at timestamptz not null,
|
||||||
|
created_at timestamptz not null,
|
||||||
|
completed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists lottery_entries (
|
||||||
|
id text primary key,
|
||||||
|
round_id text not null references lottery_rounds(id) on delete cascade,
|
||||||
|
user_id text not null references users(id) on delete cascade,
|
||||||
|
account_id text not null references accounts(id) on delete cascade,
|
||||||
|
amount_minor bigint not null check (amount_minor > 0),
|
||||||
|
created_at timestamptz not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_lottery_rounds_status_created_at on lottery_rounds(status, created_at desc);
|
||||||
|
create index if not exists idx_lottery_entries_round_id on lottery_entries(round_id, created_at asc);
|
||||||
|
create index if not exists idx_lottery_entries_user_id on lottery_entries(user_id, created_at desc);
|
||||||
|
|
@ -258,6 +258,70 @@ h1 {
|
||||||
min-width: min(100%, 360px);
|
min-width: min(100%, 360px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lottery-grid {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lottery-hero {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(255, 209, 102, 0.32), transparent 35%),
|
||||||
|
linear-gradient(140deg, #10214d 0%, #1540d1 52%, #1b8f6a 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lottery-title {
|
||||||
|
max-width: 12ch;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lottery-hero .eyebrow,
|
||||||
|
.lottery-hero .lede,
|
||||||
|
.lottery-hero .meta-line {
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lottery-stat-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lottery-stat {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lottery-stat span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lottery-stat strong {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lottery-banner {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lottery-note {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lottery-stats-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.stats-row {
|
.stats-row {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
@ -427,4 +491,9 @@ h1 {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lottery-stat-row,
|
||||||
|
.lottery-stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
<a href="/app">Dashboard</a>
|
<a href="/app">Dashboard</a>
|
||||||
<a href="/leaderboard">Leaderboard</a>
|
<a href="/leaderboard">Leaderboard</a>
|
||||||
<a href="/app/transfer">Transfer</a>
|
<a href="/app/transfer">Transfer</a>
|
||||||
|
<a href="/app/lottery">Lottery</a>
|
||||||
<a href="/app/transactions">Transactions</a>
|
<a href="/app/transactions">Transactions</a>
|
||||||
<form method="post" action="/logout" class="inline-form">
|
<form method="post" action="/logout" class="inline-form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||||
|
|
@ -61,6 +62,8 @@
|
||||||
{{ template "leaderboard" . }}
|
{{ template "leaderboard" . }}
|
||||||
{{ else if eq .Page "transfer" }}
|
{{ else if eq .Page "transfer" }}
|
||||||
{{ template "transfer" . }}
|
{{ template "transfer" . }}
|
||||||
|
{{ else if eq .Page "lottery" }}
|
||||||
|
{{ template "lottery" . }}
|
||||||
{{ else if eq .Page "transactions" }}
|
{{ else if eq .Page "transactions" }}
|
||||||
{{ template "transactions" . }}
|
{{ template "transactions" . }}
|
||||||
{{ else if eq .Page "admin_dashboard" }}
|
{{ else if eq .Page "admin_dashboard" }}
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,94 @@
|
||||||
</section>
|
</section>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "lottery" }}
|
||||||
|
{{ $page := .Data }}
|
||||||
|
<section class="stack-section">
|
||||||
|
<div class="page-grid lottery-grid">
|
||||||
|
<div class="card lottery-hero">
|
||||||
|
<p class="eyebrow">Hourly pool</p>
|
||||||
|
<h1 class="lottery-title">Deposit into the pool and wait for the next draw.</h1>
|
||||||
|
<p class="lede">Each deposit joins the active round. When the draw deadline passes, the full pool is credited to one randomly selected ticket holder.</p>
|
||||||
|
<div class="lottery-stat-row">
|
||||||
|
<article class="lottery-stat">
|
||||||
|
<span>Your balance</span>
|
||||||
|
<strong>{{ money $page.User.BalanceMinor }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="lottery-stat">
|
||||||
|
<span>Current pool</span>
|
||||||
|
<strong>{{ if $page.Lottery.ActiveRound }}{{ money $page.Lottery.ActiveRound.PoolMinor }}{{ else }}0,00 CZK{{ end }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{{ if $page.LastPayout }}
|
||||||
|
<div class="lottery-banner">
|
||||||
|
Latest draw paid <strong>{{ money $page.LastPayout.PoolMinor }}</strong> to <strong>{{ $page.LastPayout.WinnerUsername }}</strong>.
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Join the round</h2>
|
||||||
|
<form method="post" action="/app/lottery/deposit" class="stack-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||||
|
<label>Amount in CZK<input type="text" name="amount" placeholder="100,00" required></label>
|
||||||
|
<button class="button primary" type="submit">Deposit to lottery</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted lottery-note">Each deposit creates one ticket. More deposits mean more chances in the same hourly round.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-grid lottery-grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Active round</h2>
|
||||||
|
{{ if $page.Lottery.ActiveRound }}
|
||||||
|
<dl class="stats-grid lottery-stats-grid">
|
||||||
|
<div>
|
||||||
|
<dt>Draw at</dt>
|
||||||
|
<dd>{{ dt $page.Lottery.ActiveRound.DrawAt }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Participants</dt>
|
||||||
|
<dd>{{ $page.Lottery.ActiveRound.ParticipantCount }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Total tickets</dt>
|
||||||
|
<dd>{{ $page.Lottery.ActiveRound.TicketCount }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Your tickets</dt>
|
||||||
|
<dd>{{ $page.Lottery.ActiveRound.UserTicketCount }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="meta-line">Your current contribution: <strong>{{ money $page.Lottery.ActiveRound.UserContributionMinor }}</strong></p>
|
||||||
|
{{ else }}
|
||||||
|
<p class="muted">No active round yet. The first deposit starts the next one-hour lottery window.</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent winners</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>When</th><th>Winner</th><th>Pool</th><th>Tickets</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range $page.Lottery.RecentPayouts }}
|
||||||
|
<tr>
|
||||||
|
<td>{{ dt .CompletedAt }}</td>
|
||||||
|
<td>{{ .WinnerUsername }}<div class="subtle">{{ .WinnerAccount }}</div></td>
|
||||||
|
<td>{{ money .PoolMinor }}</td>
|
||||||
|
<td>{{ .TicketCount }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr><td colspan="4" class="muted">No lottery winners yet.</td></tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
{{ define "admin_dashboard" }}
|
{{ define "admin_dashboard" }}
|
||||||
{{ $page := .Data }}
|
{{ $page := .Data }}
|
||||||
<section class="stack-section">
|
<section class="stack-section">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue