Sandbox e-sports betting MVP that proves the core loop (wallet + betting + settlement) on internal test tokens — no real money, no external payment provider required to run or demo.
Public repo: safe to clone and run locally. Never commit
.envor API keys. GRID ingestion is optional (GRID_API_KEYin.env). Tokens have no cash value — seedocs/compliance-notes.md.
Payment flows use a provider-shaped architecture (M8: PaymentTransaction,
events, idempotency, mock checkout) so a future PSP can plug in without
rewriting wallet or betting. Live PSP integration is deferred (Nuvei
onboarding blocked commercially — see ADR-0011).
Feature-complete for portfolio demo on CS2 winner bets (ADR-0011):
| Shipped | Planned (later) |
|---|---|
| Session auth + registration | Dota 2 ingestion (ADR-0010) |
| Ledger wallet + mock deposits | Parlays / stat markets (new ADR) |
| GRID CS2 sync + auto-settle + undecided void | Real PSP HTTP (ADR-0011 deferred) |
| Bet placement + settlement + refunds | Scheduled GRID cron (infra) |
| Frontend: catalog, detail, results, bets, wallet | Deeper UI polish (bet slip, skeletons) |
- Sandbox-only: test balances; fund via mock deposit or
grant_tokens. - Tokens are internal test units (not real money, not crypto, not withdrawable cash).
- No external PSP credentials needed for local dev, CI, or portfolio demo.
- Games: CS2 ingested via GRID (M5b + stale cleanup + score fallback ADR-0012). Dota 2 is documented target scope — not ingested yet.
- Frontend: Next.js + TypeScript
- Backend: Django + Django REST Framework
- Auth: Django sessions
- Database: PostgreSQL
- Match data: GRID Open Access API (CS2 ingested; Dota 2 planned)
- Payments: mock provider (
NUVEI_MODE=mock); architecture swappable (ADR-0011) - Repo: Monorepo (
frontend/+django-backend/)
frontend/— Next.js appdjango-backend/— Django app + APIdocs/— product + architecture + API contracts + compliance notesinfra/— deployment/ops planning notes (infra/README.md)
- Git
- Node.js (LTS)
- Python 3.12
- PostgreSQL 14+ (the backend connects to a local Postgres on
:5432; setup steps below)
- Use
.env.exampleas the template (never commit real secrets). - Backend will use environment variables for DB connection and Django settings.
- Frontend will use environment variables for API base URL.
By convention the Python virtual environment lives at the repository root
(./.venv) and is shared by the backend tooling.
1. Clone, create venv, install dependencies
git clone git@github.com:KekoFigueroa-dev/token_e-sports_betting.git
cd token_e-sports_betting
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r django-backend/requirements.txt2. Create your local .env at the repo root
cp .env.example .envThen edit .env — at minimum, replace these placeholders:
-
DJANGO_SECRET_KEY— generate a real value:python -c "from secrets import token_urlsafe; print(token_urlsafe(50))" -
POSTGRES_PASSWORD— pick anything; whatever you set here must match the password you give your local Postgres role in step 3.
.env is gitignored — never commit it.
3. Install and start PostgreSQL locally
The backend talks to a local Postgres database. Pick the path for your OS:
Ubuntu / Debian:
sudo apt update
sudo apt install -y postgresql
sudo systemctl start postgresqlmacOS (Homebrew):
brew install postgresql@16
brew services start postgresql@16Then create the project's database and user. Replace changeme with whatever
you set for POSTGRES_PASSWORD in .env:
sudo -u postgres psql <<SQL
CREATE USER token_esports WITH PASSWORD 'changeme';
CREATE DATABASE token_esports OWNER token_esports;
ALTER USER token_esports CREATEDB; -- needed for `python manage.py test`
SQLThe
ALTER USER … CREATEDBline lets the user create temporary test databases. Skip if you don't plan to run the test suite locally.
4. Run Django
From inside django-backend/:
cd django-backend
python manage.py check # should report "0 issues"
python manage.py migrate # built-in + accounts + wallets + matches + betting + payments
python manage.py createsuperuser # prompts for email + password (no username)
python manage.py runserver # serves on http://localhost:8000The following surfaces should be live:
- http://localhost:8000/api/health/ →
JSON
{"status":"ok","service":"token-esports-backend"}(M1). - http://localhost:8000/admin → Django admin
login form (use the email + password from
createsuperuser). /api/auth/csrf/,/api/auth/register/,/api/auth/login/,/api/auth/logout/,/api/auth/me/(M2 + self-serve registration)./api/wallet/— auth-required, returns the calling user's wallet, derived balance, and the last 50 ledger entries (M4)./api/matches/and/api/matches/<uuid>/— public read-only match catalog (M5a + M6 odds; M5b GRID rows alongside fixtures). Supports pagination and catalog filters (known_teams_only,bettable_only). Aftermigrate, seed sample rows withpython manage.py loaddata sample_matches, or ingest live CS2 data:python manage.py sync_grid_matches --past-days 2 --future-days 3(requiresGRID_API_KEYin.env— see.env.example)./api/bets/— auth-required.POSTplaces a bet (requires anIdempotency-Keyheader; 201 on first creation, 200 on replay) andGETreturns the caller's own bets (M6). Seedocs/api-contracts.mdfor the full contract (including M7: settlement is CLI-only; no public settle HTTP — see that section's note)./api/payments/deposits/— auth-required.POSTstarts a sandbox Nuvei deposit (Idempotency-Keyrequired; 201 on first creation, 200 on replay),GET /api/payments/deposits/<id>/returns the caller's own deposit, and the dev-onlyPOST .../simulate-callback/is the M8 mock stand-in for Nuvei's webhook (M8). Seedocs/api-contracts.md.
Quick wallet smoke test:
# Seed your user's wallet via the dev-only management command.
# Goes through the same record_entry service every production code
# path uses; the wallet is auto-created on first access.
python manage.py grant_tokens you@example.com 100
# Re-running with the same --idempotency-key is a no-op (returns the
# same ledger entry instead of duplicating it).
python manage.py grant_tokens you@example.com 50 --idempotency-key smoke-1
python manage.py grant_tokens you@example.com 50 --idempotency-key smoke-1
# Inspect via the admin: /admin/wallets/wallet/ and /admin/wallets/ledgerentry/
# (both registered read-only — append-only invariant).Quick GRID catalog smoke test (M5b): with GRID_API_KEY set in .env:
cd django-backend
python manage.py sync_grid_matches --past-days 2 --future-days 3
curl -s 'http://localhost:8000/api/matches/?status=scheduled&known_teams_only=true&bettable_only=true&ordering=start_time&page=1' | python -m json.tool | head -20Open http://localhost:3000/matches — bettable
matches only (25 per page, real team names, soonest kickoff first).
/matches/results shows completed winners and undecided voids (GRID
finished with no winner — stakes refunded). See
django-backend/matches/README.md,
ADR-0009, and ADR-0012.
Quick bet smoke test (M6): after granting yourself tokens above,
seed matches with python manage.py loaddata sample_matches, log in
via /api/auth/login/, then POST /api/bets/ with an
Idempotency-Key header and a body like
{"match_id":"<scheduled match uuid>","selection":"team_a","stake":"50"}.
Re-posting with the same Idempotency-Key returns the original bet
with 200 OK and does NOT double-debit the wallet — that's the M6
guarantee. Full curl recipe and status-code matrix live in
docs/api-contracts.md.
Quick deposit smoke test (M8 — mock Nuvei): sign in, go to
http://localhost:3000/wallet/deposit,
enter an amount, click "Continue to checkout →". The pseudo Hosted
Payment Page at /wallet/deposit/<id>/checkout lets you click
Pay / Decline / Cancel. Pay flips the
PaymentTransaction to settled, writes one deposit ledger entry
of +amount, and the wallet badge auto-refreshes via the
wallet:refresh event. Re-clicking the same button is an idempotent
no-op (no second credit). Clicking a different button after a
terminal status returns 409 PaymentTransactionAlreadyTerminal.
The sandbox deposit also runs entirely from the API:
# 1) Sign in via the frontend or POST /api/auth/login/, then:
curl -b cookies.txt -c cookies.txt -X POST \
http://localhost:8000/api/payments/deposits/ \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $(grep csrftoken cookies.txt | awk '{print $7}')" \
-H "Idempotency-Key: smoke-1" \
-d '{"amount": "100.0000", "currency": "TOKENS"}'
# Returns 201 with hosted_page_url + status=pending_provider.
# 2) Simulate the (mock) Nuvei webhook for the returned <id>:
curl -b cookies.txt -X POST \
"http://localhost:8000/api/payments/deposits/<id>/simulate-callback/" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $(grep csrftoken cookies.txt | awk '{print $7}')" \
-d '{"result": "success"}'
# Returns 200 with status=settled. /api/wallet/ now shows +100 TOKENS.Full status-code matrix lives in
docs/api-contracts.md.
ADR for the design (state machine, three layers of idempotency,
mock-only rationale): docs/decisions/0007-m8-nuvei-deposits.md.
Quick settlement smoke test (M7): after the M6 bet smoke above,
complete a scheduled match and settle it from django-backend/:
export MATCH_UUID=22222222-2222-4222-8222-222222222201 # sample scheduled match
export WINNER_TEAM_UUID=11111111-1111-4111-8111-111111111101 # team_a of that fixture
python manage.py complete_match "$MATCH_UUID" --winner "$WINNER_TEAM_UUID"
python manage.py settle_match "$MATCH_UUID"Re-running settle_match with the same UUID is a no-op (same
SettlementEvent; no duplicate payouts). Re-running complete_match
with the same winner is also a no-op; a different winner raises
CommandError (history is not rewritten). Then GET /api/bets/ and
/api/wallet/ should show won/lost/void and an updated derived
balance; the Next.js /bets page reflects that after a refresh.
To exercise the full HTTP API end-to-end (CSRF + login + wallet + bets)
from the command line, see the auth + wallet + bets endpoint contracts
in docs/api-contracts.md.
If manage.py check and migrate both succeed and you can hit
/api/health/, the backend is set up correctly.
5. Common issues
connection refusedonmanage.py migrate: Postgres isn't running. Re-runsudo systemctl start postgresql(Linux) orbrew services start postgresql@16(macOS).password authentication failed: the password in.envdoesn't match the one you set in step 3. Either changePOSTGRES_PASSWORDin.envor re-run theALTER USER token_esports WITH PASSWORD '…'command in psql.ImproperlyConfigured: Set the DJANGO_SECRET_KEY environment variable:.envis missing or doesn't haveDJANGO_SECRET_KEY. Re-do step 2.
Code style
Python source under django-backend/ uses double-quoted strings
throughout. See docs/decisions/0002-python-code-style.md.
The Next.js app lives in frontend/. It assumes the backend is running
on http://localhost:8000 (see "Backend setup" above).
1. Install dependencies and create your local env
cd frontend
npm install
cp .env.local.example .env.localEdit .env.local only if your backend is on a different host/port. The
defaults (NEXT_PUBLIC_API_BASE_URL=http://localhost:8000, PORT=3000)
match the rest of the docs.
2. Run the dev server
npm run devOpen http://localhost:3000. With the backend running side-by-side you should see:
- A green "Backend reachable" banner (M1 health handshake).
- A session widget showing "Not signed in" with links to Create account and Sign in.
- Use Create account → (
/register) to self-register with email + password (you land signed in on/), or Sign in → (/login) with the email + password fromcreatesuperuser(or any registered user). Refresh to confirm the session persists; click Sign out to end it.
Once signed in, the following surfaces are live:
- Site header — Matches · Results · My bets · Wallet · Deposit; balance
badge links to
/wallet. /matches— public bettable catalog (25/page): confirmed teams, future kickoffs only. Match titles link to/matches/<uuid>detail. Click an odds button to expand a stake form (Idempotency-Keyper open)./matches/<uuid>— match detail: odds, status, inline betting (scheduled), or scores + winner/undecided (terminal)./matches/results— completed and undecided void matches with series scores; undecided rows explain stake refunds./bets— bet history with team names; void bets on undecided matches show a refund message./wallet— balance + last 50 ledger entries (stakes, payouts, refunds, deposits)./wallet/deposit— sandbox mock deposit → pseudo checkout at/wallet/deposit/<id>/checkout(NUVEI_MODE=mock).
Real Nuvei sandbox HTTP is deferred (ADR-0011); mock mode is the supported path.
Use this flow before a portfolio walkthrough or after cloning the repo:
- Start stack — Postgres,
python manage.py runserver(backend),npm run dev(frontend). Home page shows green Backend reachable. - Account —
/registeror/login(orcreatesuperuser+/login). - Fund —
/wallet/deposit(mock Pay) orpython manage.py grant_tokens you@example.com 500. - Matches — optional:
python manage.py sync_grid_matches --past-days 2 --future-days 3(needsGRID_API_KEY). Orloaddata sample_matchesfor fixtures only. - Bet —
/matches→ pick odds → confirm stake. Balance drops viabet_stake. - Settle — re-run
sync_grid_matchesafter real matches finish (GRID auto-settles), or dev path:complete_match+settle_matchon a fixture. - Verify —
/bets(won/lost/void),/wallet(ledger),/matches/results(winner or undecided + refund copy).
Idempotency smoke: replay the same bet Idempotency-Key → 200 and no double debit;
replay sync_grid_matches on a settled match → no duplicate payouts.
3. Common scripts
npm run dev # local dev server with hot reload
npm run build # production build (also catches TypeScript errors)
npm run start # serve the production build locally
npm run lint # ESLint4. Conventions
- All backend calls go through
frontend/src/lib/api.ts. It setscredentials: "include"so the browser attaches Django session cookies on cross-origin requests. .env.localis gitignored;.env.local.exampleis the committed template.- Tailwind CSS is wired up; styles live in
frontend/src/app/globals.css.
Pull requests and pushes to main run GitHub Actions
(.github/workflows/ci.yml):
- Backend: Python 3.12, PostgreSQL 16 (service container), full suite
python manage.py testfromdjango-backend/. - Frontend: Node 22,
npm ci,npm run lint,npx tsc --noEmit.next buildis intentionally not in CI yet (optional follow-up).npm auditis not part of CI; if it flags nested PostCSS insidenext, do not runnpm audit fix --force— seefrontend/README.md.
Match CI locally before you push: docs/ci.md. After the
first green run on main, you can require these checks under GitHub →
Settings → Branches → Branch protection rules (optional).
docs/ci.md— what CI runs and how to reproduce it locally.docs/architecture.md— domain boundaries, traceability, idempotency.docs/api-contracts.md— endpoint shapes (Implemented vs Planned).docs/compliance-notes.md— sandbox boundaries.docs/decisions/README.md— index of all ADRs.docs/decisions/0001-use-monorepo.mddocs/decisions/0002-python-code-style.mddocs/decisions/0003-database-conventions.md— UUIDs, decimals, idempotency, traceability.docs/decisions/0004-m7-bet-settlement.md— M7 settlement design (CLI-first, void path, counters, idempotency).docs/decisions/0005-cross-origin-session-spa.md— CORS + CSRF + credentials for Next.js ↔ Django.docs/decisions/0006-match-odds-and-bettability-m6.md— odds onMatch, bettableness, frozenodds_snapshot.docs/decisions/0007-m8-nuvei-deposits.md— M8 mock Nuvei deposits, three-layer idempotency, deferred sandbox HTTP.docs/decisions/0008-self-serve-registration.md— self-servePOST /api/auth/register/, session on success, sandbox identity boundaries.docs/decisions/0009-m5b-grid-ingestion.md— M5b GRID CS2 sync (shipped).docs/decisions/0010-multi-game-catalog-target.md— multi-game catalog target (planned).docs/decisions/0011-psp-deferred-mvp-completion.md— mock-only MVP; PSP deferred.docs/decisions/0012-grid-score-winner-fallback.md— GRID score fallback + undecided void.docs/vscode-collaboration-setup.md- Per-app READMEs under
django-backend/<app>/README.md(wallets/,payments/,matches/,betting/).
Format:
<type>/<owner>/<short-description>
Examples:
feature/keko/auth-sessionsfeature/emi/match-list-uidocs/keko/readme-setup
See docs/architecture.md for detail.
Shipped on main: CS2 GRID sync (stale pass, score fallback, undecided void),
core frontend nav + match detail + wallet ledger view.
Planned (not started — pick up when resuming):
- Dota 2 ingestion — verify GRID Open Access scope, then mirror CS2 (ADR-0010)
- Betting depth — parlays / stat markets (needs new ADR)
- Frontend polish — bet slip UX, loading skeletons, mobile pass
- GRID ops —
--stale-onlyflag, wider lookback, optional cron (infra) - Real PSP HTTP — deferred until provider access exists (ADR-0011)
We intentionally scaffolded structure + docs first to keep implementation clean and repeatable before feature coding.