diff --git a/internal/db/store.go b/internal/db/store.go index 8aade47..c90498f 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "math/big" "sort" "strings" "time" @@ -77,6 +78,31 @@ type LeaderboardEntry struct { 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 { Action string TargetType string @@ -95,6 +121,21 @@ type AdminDashboard struct { 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 { return &Store{pool: pool} } @@ -536,6 +577,210 @@ func (s *Store) CreateTransfer(ctx context.Context, senderUserID, destinationAcc 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) { var dashboard AdminDashboard 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 } +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) { for i := 0; i < 20; i++ { candidate, err := randomAccountNumber() @@ -853,6 +1263,19 @@ func randomAccountNumber() (string, error) { 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) { var balance int64 err := tx.QueryRow(ctx, ` diff --git a/internal/http/app.go b/internal/http/app.go index 9977e4d..4d24bcd 100644 --- a/internal/http/app.go +++ b/internal/http/app.go @@ -53,6 +53,12 @@ type DashboardData struct { Transactions []db.TransactionView } +type LotteryPageData struct { + User *db.User + Lottery *db.LotteryDashboard + LastPayout *db.LotteryPayoutView +} + type AdminDashboardData struct { Admin *db.AdminUser Dashboard *db.AdminDashboard @@ -158,6 +164,8 @@ func (a *App) routes() http.Handler { protected.Get("/app", a.userDashboard) protected.Get("/app/transfer", a.showTransfer) 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) }) @@ -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}}) } +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) { a.render(w, http.StatusOK, ViewData{Title: "Admin login", Page: "admin_login", CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r)}) } diff --git a/migrations/002_lottery.sql b/migrations/002_lottery.sql new file mode 100644 index 0000000..ceb75d4 --- /dev/null +++ b/migrations/002_lottery.sql @@ -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); diff --git a/web/static/app.css b/web/static/app.css index cd45b5a..f97287e 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -258,6 +258,70 @@ h1 { 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 { align-items: stretch; } @@ -427,4 +491,9 @@ h1 { flex-wrap: wrap; justify-content: center; } + + .lottery-stat-row, + .lottery-stats-grid { + grid-template-columns: 1fr; + } } diff --git a/web/templates/layouts/base.gohtml b/web/templates/layouts/base.gohtml index 4c9fff0..8b2a726 100644 --- a/web/templates/layouts/base.gohtml +++ b/web/templates/layouts/base.gohtml @@ -25,6 +25,7 @@ Dashboard Leaderboard Transfer + Lottery Transactions