This commit is contained in:
pavel 2026-04-08 21:42:09 +02:00
commit b918b92a4d
6 changed files with 702 additions and 0 deletions

View file

@ -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, `

View file

@ -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)})
}