diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0f7ffaf
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,32 @@
+# Django environment
+# Use "development" locally. Omit or set a production value in production.
+PYTHON_ENV=development
+
+# Django/API secrets
+SECRET_KEY=replace-me
+API_KEY=replace-me
+
+# Used in highscores clean-code decoding
+NEW_AES_KEY=0123456789abcdef
+
+# Discord OAuth
+DISCORD_CLIENT_SECRET=replace-me
+
+# Email
+SMTP_SERVER=smtp.example.com
+SMTP_PASSWORD=replace-me
+
+# Optional Discord webhook for highscores notifications
+HIGHSCORES_WEBHOOK_URL=
+
+# Polar subscriptions
+POLAR_API_BASE_URL=https://api.polar.sh
+POLAR_ACCESS_TOKEN=
+POLAR_WEBHOOK_SECRET=
+POLAR_CHECKOUT_SUCCESS_URL=
+POLAR_CUSTOMER_PORTAL_URL=
+
+# xRC server orchestrator
+ORCHESTRATOR_API_BASE_URL=
+ORCHESTRATOR_API_TOKEN=
+ORCHESTRATOR_REQUEST_TIMEOUT_SECONDS=10
diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml
index 07fdd39..81ca6c4 100644
--- a/.github/workflows/django.yml
+++ b/.github/workflows/django.yml
@@ -1,42 +1,81 @@
-name: 🚀 Deploy Django
+name: Deploy Django
on:
push:
branches: [main]
jobs:
- build-deploy:
- name: 🎉 Build and Deploy
+ validate:
+ name: Validate
runs-on: ubuntu-latest
steps:
- - name: 🚚 Get latest code
- uses: actions/checkout@v2
+ - name: Checkout
+ uses: actions/checkout@v4
- - name: 🔨 Set up Python 3.12
- uses: actions/setup-python@v2
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v5
with:
- python-version: 3.12
+ python-version: "3.12"
+ cache: pip
- - name: 🛠 Install Dependencies
+ - name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- - name: ▶ Run Tests
- run: |
- python manage.py test
+ - name: Check for missing migrations
+ run: python manage.py makemigrations --check --dry-run
env:
- SECRET_KEY: ${{ secrets.BASE_KEY }}
- SMTP_SERVER: ${{ secrets.SMTP_SERVER }}
- SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
- NEW_AES_KEY: ${{ secrets.NEW_AES_KEY }}
- DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
+ SECRET_KEY: ci-secret-key
+ NEW_AES_KEY: 0123456789abcdef
+ DISCORD_CLIENT_SECRET: ci-discord-secret
+
+ - name: Apply migrations on a fresh database
+ run: python manage.py migrate --noinput
+ env:
+ SECRET_KEY: ci-secret-key
+ NEW_AES_KEY: 0123456789abcdef
+ DISCORD_CLIENT_SECRET: ci-discord-secret
+
+ - name: Run Django checks
+ run: python manage.py check
+ env:
+ SECRET_KEY: ci-secret-key
+ NEW_AES_KEY: 0123456789abcdef
+ DISCORD_CLIENT_SECRET: ci-discord-secret
+
+ - name: Run tests
+ run: pytest
+ env:
+ SECRET_KEY: ci-secret-key
+ NEW_AES_KEY: 0123456789abcdef
+ DISCORD_CLIENT_SECRET: ci-discord-secret
+
+ build-deploy:
+ name: Build and Deploy
+ needs: validate
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
- - name: 📦 Collect Static Files
+ - name: Install dependencies
run: |
- python manage.py collectstatic --noinput
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+
+ - name: Collect static files
+ run: python manage.py collectstatic --noinput
env:
SECRET_KEY: ${{ secrets.BASE_KEY }}
SMTP_SERVER: ${{ secrets.SMTP_SERVER }}
@@ -44,12 +83,12 @@ jobs:
NEW_AES_KEY: ${{ secrets.NEW_AES_KEY }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
- - name: 🔃 Restart Server
+ - name: Restart server
run: |
mkdir tmp
touch tmp/restart.txt
- - name: ☁ SFTP Deploy
+ - name: SFTP deploy
uses: easingthemes/ssh-deploy@v2
env:
SSH_PRIVATE_KEY: ${{ secrets.SERVER_SSH_KEY }}
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 1b5b072..65f92c3 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -9,23 +9,43 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
- - name: 🔨 Set up Python 3.12
- uses: actions/setup-python@v2
+ - uses: actions/checkout@v4
+
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v5
with:
- python-version: 3.12
+ python-version: "3.12"
+ cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
+
+ - name: Check for missing migrations
+ run: python manage.py makemigrations --check --dry-run
+ env:
+ SECRET_KEY: ci-secret-key
+ NEW_AES_KEY: 0123456789abcdef
+ DISCORD_CLIENT_SECRET: ci-discord-secret
+
+ - name: Apply migrations on a fresh database
+ run: python manage.py migrate --noinput
+ env:
+ SECRET_KEY: ci-secret-key
+ NEW_AES_KEY: 0123456789abcdef
+ DISCORD_CLIENT_SECRET: ci-discord-secret
+
+ - name: Run Django checks
+ run: python manage.py check
+ env:
+ SECRET_KEY: ci-secret-key
+ NEW_AES_KEY: 0123456789abcdef
+ DISCORD_CLIENT_SECRET: ci-discord-secret
- - name: ▶ Run Tests
- run: |
- pytest
+ - name: Run tests
+ run: pytest
env:
- SECRET_KEY: ${{ secrets.BASE_KEY }}
- SMTP_SERVER: ${{ secrets.SMTP_SERVER }}
- SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
- NEW_AES_KEY: ${{ secrets.NEW_AES_KEY }}
- DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
+ SECRET_KEY: ci-secret-key
+ NEW_AES_KEY: 0123456789abcdef
+ DISCORD_CLIENT_SECRET: ci-discord-secret
diff --git a/.gitignore b/.gitignore
index c3b2e73..646d662 100644
--- a/.gitignore
+++ b/.gitignore
@@ -132,7 +132,6 @@ dmypy.json
# Database
*.sqlite3
-migrations/
.vscode/
.DS_Store
diff --git a/SRCweb/settings.py b/SRCweb/settings.py
index 97d7dbe..cb659a4 100644
--- a/SRCweb/settings.py
+++ b/SRCweb/settings.py
@@ -37,6 +37,18 @@
# SECURITY WARNING: keep the API Key used in production secret!
API_KEY = os.getenv("API_KEY") or "API_KEY"
+# Polar subscriptions
+POLAR_API_BASE_URL = os.getenv("POLAR_API_BASE_URL") or "https://api.polar.sh"
+POLAR_ACCESS_TOKEN = os.getenv("POLAR_ACCESS_TOKEN") or ""
+POLAR_WEBHOOK_SECRET = os.getenv("POLAR_WEBHOOK_SECRET") or ""
+POLAR_CHECKOUT_SUCCESS_URL = os.getenv("POLAR_CHECKOUT_SUCCESS_URL") or ""
+POLAR_CUSTOMER_PORTAL_URL = os.getenv("POLAR_CUSTOMER_PORTAL_URL") or ""
+
+# xRC server orchestrator
+ORCHESTRATOR_API_BASE_URL = os.getenv("ORCHESTRATOR_API_BASE_URL") or ""
+ORCHESTRATOR_API_TOKEN = os.getenv("ORCHESTRATOR_API_TOKEN") or ""
+ORCHESTRATOR_REQUEST_TIMEOUT_SECONDS = float(os.getenv("ORCHESTRATOR_REQUEST_TIMEOUT_SECONDS") or 10)
+
# Sends an email to admins when debug = false and a 500 server error occurs
ADMINS = [('Webmaster', 'webmaster@secondrobotics.org')]
ADMIN_EMAILS = [email for name, email in ADMINS]
@@ -73,7 +85,8 @@
# 'teamleague',
# 'ladder',
'discordoauth2',
- 'ranked'
+ 'ranked',
+ 'subscriptions',
]
REST_FRAMEWORK = {
diff --git a/SRCweb/urls.py b/SRCweb/urls.py
index 31ab48c..bca3fb1 100644
--- a/SRCweb/urls.py
+++ b/SRCweb/urls.py
@@ -30,6 +30,8 @@
path('oauth2/', include('discordoauth2.urls')),
path('ranked/', include('ranked.urls')),
path('api/ranked/', include('ranked.api.urls', namespace="api-ranked")),
+ path('subscriptions/', include('subscriptions.urls')),
+ path('api/subscriptions/', include('subscriptions.api.urls', namespace="api-subscriptions")),
]
if settings.DEBUG:
diff --git a/discordoauth2/migrations/0001_initial.py b/discordoauth2/migrations/0001_initial.py
new file mode 100644
index 0000000..11d0b77
--- /dev/null
+++ b/discordoauth2/migrations/0001_initial.py
@@ -0,0 +1,53 @@
+# Generated by Django 3.2.25 on 2026-05-28 04:36
+
+import discordoauth2.managers
+import django.contrib.auth.validators
+import django.core.validators
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='User',
+ fields=[
+ ('password', models.CharField(max_length=128, verbose_name='password')),
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+ ('display_name', models.CharField(max_length=25, null=True, validators=[django.contrib.auth.validators.ASCIIUsernameValidator(), django.core.validators.MinLengthValidator(4)], verbose_name='display name')),
+ ('id', models.BigIntegerField(primary_key=True, serialize=False, verbose_name='user id')),
+ ('username', models.CharField(max_length=100, verbose_name='username')),
+ ('discriminator', models.CharField(max_length=4, verbose_name='discriminator')),
+ ('avatar', models.URLField(verbose_name='avatar')),
+ ('public_flags', models.IntegerField(verbose_name='public flags')),
+ ('flags', models.IntegerField(verbose_name='flags')),
+ ('locale', models.CharField(max_length=100, verbose_name='locale')),
+ ('mfa_enabled', models.BooleanField(verbose_name='multi-factor authentication enabled')),
+ ('email', models.EmailField(max_length=254, verbose_name='email address')),
+ ('verified', models.BooleanField(help_text='Whether the user has verified their email address.', verbose_name='email verified')),
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff')),
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
+ ],
+ options={
+ 'verbose_name': 'user',
+ 'verbose_name_plural': 'users',
+ 'abstract': False,
+ },
+ managers=[
+ ('objects', discordoauth2.managers.DiscordUserOAuth2Manager()),
+ ],
+ ),
+ ]
diff --git a/discordoauth2/migrations/__init__.py b/discordoauth2/migrations/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/discordoauth2/migrations/__init__.py
@@ -0,0 +1 @@
+
diff --git a/events/migrations/0001_initial.py b/events/migrations/0001_initial.py
new file mode 100644
index 0000000..8953f1a
--- /dev/null
+++ b/events/migrations/0001_initial.py
@@ -0,0 +1,110 @@
+# Generated by Django 3.2.25 on 2026-05-28 04:36
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ChampionshipPoints',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('player_name', models.CharField(max_length=25)),
+ ('event_1', models.IntegerField(default=0)),
+ ('event_2', models.IntegerField(default=0)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Event',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=25)),
+ ('start_time', models.DateTimeField()),
+ ('end_time', models.DateTimeField()),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Ranking',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('player_name', models.CharField(max_length=25)),
+ ('ranking_points', models.IntegerField()),
+ ('time_set', models.DateTimeField(blank=True, null=True)),
+ ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Player',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('player_name', models.CharField(max_length=25)),
+ ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Match',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('match_number', models.IntegerField(blank=True, null=True)),
+ ('match_type', models.CharField(choices=[('q', 'Qualifications'), ('qf', 'Quarterfinals'), ('sf', 'Semifinals'), ('f', 'Finals')], default='q', max_length=2)),
+ ('red1', models.CharField(max_length=25)),
+ ('red2', models.CharField(max_length=25)),
+ ('red3', models.CharField(max_length=25)),
+ ('blue1', models.CharField(max_length=25)),
+ ('blue2', models.CharField(max_length=25)),
+ ('blue3', models.CharField(max_length=25)),
+ ('red1_redcard', models.BooleanField(default=False)),
+ ('red2_redcard', models.BooleanField(default=False)),
+ ('red3_redcard', models.BooleanField(default=False)),
+ ('blue1_redcard', models.BooleanField(default=False)),
+ ('blue2_redcard', models.BooleanField(default=False)),
+ ('blue3_redcard', models.BooleanField(default=False)),
+ ('red1_surrogate', models.BooleanField(default=False)),
+ ('red2_surrogate', models.BooleanField(default=False)),
+ ('red3_surrogate', models.BooleanField(default=False)),
+ ('blue1_surrogate', models.BooleanField(default=False)),
+ ('blue2_surrogate', models.BooleanField(default=False)),
+ ('blue3_surrogate', models.BooleanField(default=False)),
+ ('red1_contribution', models.IntegerField(blank=True, null=True)),
+ ('red2_contribution', models.IntegerField(blank=True, null=True)),
+ ('red3_contribution', models.IntegerField(blank=True, null=True)),
+ ('blue1_contribution', models.IntegerField(blank=True, null=True)),
+ ('blue2_contribution', models.IntegerField(blank=True, null=True)),
+ ('blue3_contribution', models.IntegerField(blank=True, null=True)),
+ ('red_score', models.IntegerField(blank=True, null=True)),
+ ('red_climb_rp', models.BooleanField(blank=True, null=True)),
+ ('red_wheel_rp', models.BooleanField(blank=True, null=True)),
+ ('red_auto_points', models.IntegerField(blank=True, null=True)),
+ ('red_teleop_points', models.IntegerField(blank=True, null=True)),
+ ('red_endgame_points', models.IntegerField(blank=True, null=True)),
+ ('red_power_cells', models.IntegerField(blank=True, null=True)),
+ ('blue_score', models.IntegerField(blank=True, null=True)),
+ ('blue_climb_rp', models.BooleanField(blank=True, null=True)),
+ ('blue_wheel_rp', models.BooleanField(blank=True, null=True)),
+ ('blue_auto_points', models.IntegerField(blank=True, null=True)),
+ ('blue_teleop_points', models.IntegerField(blank=True, null=True)),
+ ('blue_endgame_points', models.IntegerField(blank=True, null=True)),
+ ('blue_power_cells', models.IntegerField(blank=True, null=True)),
+ ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ElimsAlliance',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('alliance_number', models.IntegerField(blank=True, null=True)),
+ ('player1', models.CharField(max_length=25)),
+ ('player2', models.CharField(max_length=25)),
+ ('player3', models.CharField(max_length=25)),
+ ('advancement', models.CharField(choices=[('qf', 'QF'), ('sf', 'SF'), ('f', 'F'), ('w', 'W')], default='qf', max_length=25)),
+ ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')),
+ ],
+ ),
+ ]
diff --git a/events/migrations/__init__.py b/events/migrations/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/events/migrations/__init__.py
@@ -0,0 +1 @@
+
diff --git a/highscores/migrations/0001_initial.py b/highscores/migrations/0001_initial.py
new file mode 100644
index 0000000..8a57cd6
--- /dev/null
+++ b/highscores/migrations/0001_initial.py
@@ -0,0 +1,80 @@
+# Generated by Django 3.2.25 on 2026-05-28 04:36
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ExemptedIP',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('ip', models.CharField(max_length=45)),
+ ('reason', models.CharField(blank=True, max_length=200, null=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Leaderboard',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=25)),
+ ('robot', models.CharField(max_length=25)),
+ ('game', models.CharField(max_length=25)),
+ ('game_slug', models.CharField(max_length=5)),
+ ('auto_or_teleop', models.CharField(default='TELE', max_length=4)),
+ ('message', models.CharField(blank=True, max_length=200, null=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Score',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('score', models.IntegerField()),
+ ('time_set', models.DateTimeField(auto_now_add=True)),
+ ('source', models.URLField()),
+ ('approved', models.BooleanField(default=False)),
+ ('clean_code', models.CharField(max_length=4000)),
+ ('decrypted_code', models.CharField(blank=True, max_length=2000, null=True)),
+ ('client_version', models.CharField(blank=True, max_length=20, null=True)),
+ ('time_of_score', models.CharField(blank=True, max_length=30, null=True)),
+ ('robot_position', models.CharField(blank=True, max_length=20, null=True)),
+ ('time_data', models.TextField(blank=True, null=True)),
+ ('ip', models.CharField(blank=True, max_length=45, null=True)),
+ ('leaderboard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='highscores.leaderboard')),
+ ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='CleanCodeSubmission',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('clean_code', models.CharField(max_length=600)),
+ ('score', models.IntegerField()),
+ ('time_set', models.DateTimeField(auto_now_add=True)),
+ ('ip', models.CharField(blank=True, max_length=45, null=True)),
+ ('leaderboard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='highscores.leaderboard')),
+ ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.AddIndex(
+ model_name='score',
+ index=models.Index(fields=['leaderboard'], name='highscores__leaderb_ad4364_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='score',
+ index=models.Index(fields=['player'], name='highscores__player__3776b0_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='score',
+ index=models.Index(fields=['approved'], name='highscores__approve_054a97_idx'),
+ ),
+ ]
diff --git a/highscores/migrations/__init__.py b/highscores/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/home/migrations/0001_initial.py b/home/migrations/0001_initial.py
new file mode 100644
index 0000000..b5f9d0a
--- /dev/null
+++ b/home/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.2.25 on 2026-05-28 04:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='HistoricEvent',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=50)),
+ ('date', models.DateField()),
+ ('youtube_url', models.URLField(blank=True, null=True)),
+ ('first_place', models.CharField(max_length=100)),
+ ('second_place', models.CharField(blank=True, max_length=100, null=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Staff',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=50)),
+ ('title', models.CharField(max_length=50)),
+ ('image_url', models.URLField()),
+ ('bio', models.TextField()),
+ ('linkedin_url', models.URLField(blank=True, null=True)),
+ ('github_url', models.URLField(blank=True, null=True)),
+ ('email', models.EmailField(max_length=254)),
+ ],
+ ),
+ ]
diff --git a/home/migrations/__init__.py b/home/migrations/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/home/migrations/__init__.py
@@ -0,0 +1 @@
+
diff --git a/home/templates/home/base.html b/home/templates/home/base.html
index c19e808..970c734 100644
--- a/home/templates/home/base.html
+++ b/home/templates/home/base.html
@@ -243,6 +243,15 @@
+
+
+ Premium
+
/entitlement/', views.user_entitlement, name='user entitlement'),
+ path('casual-games/', views.casual_games, name='casual games'),
+ path('server-sessions/start/', views.start_session, name='start session'),
+ path('server-sessions//stop/', views.stop_session, name='stop session'),
+ path('server-sessions//orchestrator-event/', views.orchestrator_event, name='orchestrator event'),
+]
diff --git a/subscriptions/api/views.py b/subscriptions/api/views.py
new file mode 100644
index 0000000..f14f006
--- /dev/null
+++ b/subscriptions/api/views.py
@@ -0,0 +1,147 @@
+from django.conf import settings
+from rest_framework.decorators import api_view
+from rest_framework.response import Response
+
+from discordoauth2.models import User
+from subscriptions.models import ServerSession
+from subscriptions import orchestrator
+from subscriptions.services import (
+ SubscriptionError,
+ entitlement_payload,
+ launch_casual_server,
+ process_orchestrator_event,
+ request_stop_server_session,
+ serialize_session,
+)
+
+
+def _require_auth(request):
+ if not request.user.is_authenticated:
+ return Response({'success': False, 'message': 'User is not authenticated.'}, status=401)
+ return None
+
+
+def _is_service_request(request):
+ expected = getattr(settings, 'API_KEY', '')
+ provided = request.META.get('HTTP_X_API_KEY', '')
+ return bool(expected and provided and provided == expected)
+
+
+def _require_auth_or_service(request):
+ if request.user.is_authenticated or _is_service_request(request):
+ return None
+ return Response({'success': False, 'message': 'User is not authenticated.'}, status=401)
+
+
+def _resolve_launch_user(request):
+ if _is_service_request(request):
+ owner_discord_id = (request.data or {}).get('owner_discord_id') or (request.data or {}).get('ownerDiscordUserId')
+ if not owner_discord_id:
+ raise SubscriptionError('Service launch requests require owner_discord_id.')
+ try:
+ return User.objects.get(id=owner_discord_id)
+ except User.DoesNotExist as exc:
+ raise SubscriptionError('The requested Discord user is not linked to SecondWebsite.') from exc
+ return request.user
+
+
+@api_view(['GET'])
+def my_entitlement(request):
+ auth_error = _require_auth(request)
+ if auth_error:
+ return auth_error
+ return Response({'success': True, 'entitlement': entitlement_payload(request.user)})
+
+
+@api_view(['GET'])
+def user_entitlement(request, discord_user_id):
+ auth_error = _require_auth_or_service(request)
+ if auth_error:
+ return auth_error
+ if not _is_service_request(request):
+ return Response({'success': False, 'message': 'Service API key is required.'}, status=403)
+ try:
+ user = User.objects.get(id=discord_user_id)
+ except User.DoesNotExist:
+ return Response({'success': False, 'message': 'The requested Discord user is not linked to SecondWebsite.'}, status=404)
+ return Response({'success': True, 'discord_user_id': str(user.id), 'entitlement': entitlement_payload(user)})
+
+
+@api_view(['GET'])
+def casual_games(request):
+ auth_error = _require_auth_or_service(request)
+ if auth_error:
+ return auth_error
+ try:
+ games = orchestrator.get_casual_games()
+ except orchestrator.OrchestratorError as exc:
+ return Response({'success': False, 'message': str(exc), 'code': exc.code}, status=503)
+ return Response({'success': True, 'games': games})
+
+
+@api_view(['POST'])
+def start_session(request):
+ auth_error = _require_auth_or_service(request)
+ if auth_error:
+ return auth_error
+
+ data = request.data or {}
+ game_code = data.get('game_code') or data.get('gameCode')
+ if not game_code:
+ return Response({'success': False, 'message': 'game_code is required.'}, status=400)
+
+ try:
+ launch_user = _resolve_launch_user(request)
+ session = launch_casual_server(
+ launch_user,
+ requested_minutes=data.get('requested_minutes') or data.get('requestedMinutes'),
+ game=data.get('game', '') or str(game_code),
+ game_code=game_code,
+ comment=data.get('comment', ''),
+ password=data.get('password', ''),
+ launch_source=data.get('launch_source') or data.get('launchSource') or ('discord_bot' if _is_service_request(request) else 'website'),
+ launch_context=data.get('launch_context', {}),
+ )
+ except SubscriptionError as exc:
+ return Response({'success': False, 'message': str(exc), 'code': exc.code}, status=exc.status_code)
+
+ return Response({'success': True, 'session': serialize_session(session)}, status=201)
+
+
+@api_view(['POST'])
+def stop_session(request, session_id):
+ auth_error = _require_auth_or_service(request)
+ if auth_error:
+ return auth_error
+
+ try:
+ query = ServerSession.objects
+ if not _is_service_request(request):
+ query = query.filter(user=request.user)
+ session = query.get(id=session_id)
+ except ServerSession.DoesNotExist:
+ return Response({'success': False, 'message': 'Server session does not exist.'}, status=404)
+
+ try:
+ session = request_stop_server_session(session, reason=(request.data or {}).get('reason', 'stopped'))
+ except SubscriptionError as exc:
+ return Response({'success': False, 'message': str(exc), 'code': exc.code}, status=exc.status_code)
+ return Response({'success': True, 'session': serialize_session(session)})
+
+
+@api_view(['POST'])
+def orchestrator_event(request, session_id):
+ auth_error = _require_auth_or_service(request)
+ if auth_error:
+ return auth_error
+ if not _is_service_request(request):
+ return Response({'success': False, 'message': 'Service API key is required.'}, status=403)
+
+ try:
+ event = process_orchestrator_event(session_id, request.data or {})
+ except ServerSession.DoesNotExist:
+ return Response({'success': False, 'message': 'Server session does not exist.'}, status=404)
+ except SubscriptionError as exc:
+ return Response({'success': False, 'message': str(exc)}, status=400)
+
+ return Response({'success': True, 'event_id': event.event_id, 'status': event.status})
diff --git a/subscriptions/apps.py b/subscriptions/apps.py
new file mode 100644
index 0000000..b583f10
--- /dev/null
+++ b/subscriptions/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class SubscriptionsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'subscriptions'
diff --git a/subscriptions/management/__init__.py b/subscriptions/management/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/subscriptions/management/__init__.py
@@ -0,0 +1 @@
+
diff --git a/subscriptions/management/commands/__init__.py b/subscriptions/management/commands/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/subscriptions/management/commands/__init__.py
@@ -0,0 +1 @@
+
diff --git a/subscriptions/management/commands/expire_premium_sessions.py b/subscriptions/management/commands/expire_premium_sessions.py
new file mode 100644
index 0000000..56beccb
--- /dev/null
+++ b/subscriptions/management/commands/expire_premium_sessions.py
@@ -0,0 +1,19 @@
+from django.core.management.base import BaseCommand
+
+from subscriptions.services import expire_stale_sessions
+
+
+class Command(BaseCommand):
+ help = 'Expire premium casual server sessions that have stopped heartbeating.'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--max-heartbeat-age-minutes',
+ type=int,
+ default=10,
+ help='How many minutes a running session can go without a heartbeat before expiring.',
+ )
+
+ def handle(self, *args, **options):
+ expired = expire_stale_sessions(max_heartbeat_age_minutes=options['max_heartbeat_age_minutes'])
+ self.stdout.write(self.style.SUCCESS(f'Expired {len(expired)} premium server session(s).'))
diff --git a/subscriptions/migrations/0001_initial.py b/subscriptions/migrations/0001_initial.py
new file mode 100644
index 0000000..2247bca
--- /dev/null
+++ b/subscriptions/migrations/0001_initial.py
@@ -0,0 +1,189 @@
+# Generated by Django 3.2.25 on 2026-05-28 04:36
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ServerSession',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('launching', 'Launching'), ('running', 'Running'), ('stopping', 'Stopping'), ('stopped', 'Stopped'), ('failed', 'Failed'), ('expired', 'Expired'), ('killed', 'Killed')], default='running', max_length=16)),
+ ('requested_minutes', models.PositiveIntegerField()),
+ ('allocated_minutes', models.PositiveIntegerField()),
+ ('game', models.CharField(blank=True, max_length=80)),
+ ('game_code', models.CharField(blank=True, max_length=32)),
+ ('comment', models.CharField(blank=True, max_length=120)),
+ ('server_identifier', models.CharField(blank=True, max_length=128)),
+ ('orchestrator_session_id', models.CharField(blank=True, max_length=128)),
+ ('orchestrator_state', models.CharField(blank=True, max_length=64)),
+ ('host', models.CharField(blank=True, max_length=255)),
+ ('port', models.PositiveIntegerField(blank=True, null=True)),
+ ('server_password', models.CharField(blank=True, max_length=80)),
+ ('ready_at', models.DateTimeField(blank=True, null=True)),
+ ('orchestrator_stopped_at', models.DateTimeField(blank=True, null=True)),
+ ('launch_error_code', models.CharField(blank=True, max_length=80)),
+ ('launch_error_message', models.TextField(blank=True)),
+ ('launch_context', models.JSONField(blank=True, default=dict)),
+ ('started_at', models.DateTimeField(default=django.utils.timezone.now)),
+ ('last_heartbeat_at', models.DateTimeField(default=django.utils.timezone.now)),
+ ('stopped_at', models.DateTimeField(blank=True, null=True)),
+ ('stop_reason', models.CharField(blank=True, max_length=80)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'ordering': ['-started_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='SubscriptionEntitlement',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('provider', models.CharField(choices=[('polar', 'Polar'), ('discord_server_subscription', 'Discord Server Subscription'), ('github_sponsors', 'GitHub Sponsors'), ('comp', 'Comp')], max_length=40)),
+ ('provider_customer_id', models.CharField(blank=True, max_length=128)),
+ ('provider_subscription_id', models.CharField(blank=True, max_length=128)),
+ ('status', models.CharField(choices=[('active', 'Active'), ('trialing', 'Trialing'), ('past_due', 'Past Due'), ('canceled', 'Canceled'), ('expired', 'Expired'), ('inactive', 'Inactive')], default='active', max_length=24)),
+ ('current_period_start', models.DateTimeField(default=django.utils.timezone.now)),
+ ('current_period_end', models.DateTimeField()),
+ ('cancel_at_period_end', models.BooleanField(default=False)),
+ ('latest_event_id', models.CharField(blank=True, max_length=128)),
+ ('metadata', models.JSONField(blank=True, default=dict)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='SubscriptionPlan',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('tier', models.SlugField(help_text='Stable plan identifier used in checkout URLs, Polar metadata, and API responses.', max_length=64, unique=True)),
+ ('name', models.CharField(max_length=80)),
+ ('description', models.TextField(blank=True)),
+ ('benefit_lines', models.TextField(blank=True, help_text='Optional website benefit copy, one benefit per line.')),
+ ('monthly_price_usd', models.PositiveIntegerField()),
+ ('monthly_server_minutes', models.PositiveIntegerField()),
+ ('max_session_minutes', models.PositiveIntegerField()),
+ ('max_concurrent_servers', models.PositiveIntegerField(default=1)),
+ ('polar_product_id', models.CharField(blank=True, max_length=128)),
+ ('discord_role_id', models.CharField(blank=True, max_length=64)),
+ ('entitlement_priority', models.IntegerField(default=0, help_text='Higher priority wins when a user has multiple active entitlements.')),
+ ('display_order', models.PositiveIntegerField(default=0)),
+ ('is_active', models.BooleanField(default=True)),
+ ],
+ options={
+ 'ordering': ['display_order', 'monthly_price_usd', 'name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='UsageLedgerEntry',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('billing_period_start', models.DateTimeField()),
+ ('billing_period_end', models.DateTimeField()),
+ ('minutes', models.IntegerField()),
+ ('reason', models.CharField(choices=[('server_session', 'Server Session'), ('admin_adjustment', 'Admin Adjustment')], max_length=32)),
+ ('metadata', models.JSONField(blank=True, default=dict)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='WebhookEvent',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('provider', models.CharField(max_length=40)),
+ ('event_id', models.CharField(max_length=128)),
+ ('event_type', models.CharField(max_length=120)),
+ ('payload', models.JSONField(default=dict)),
+ ('status', models.CharField(default='processed', max_length=16)),
+ ('error', models.TextField(blank=True)),
+ ('received_at', models.DateTimeField(auto_now_add=True)),
+ ('processed_at', models.DateTimeField(blank=True, null=True)),
+ ],
+ options={
+ 'ordering': ['-received_at'],
+ },
+ ),
+ migrations.AddConstraint(
+ model_name='webhookevent',
+ constraint=models.UniqueConstraint(fields=('provider', 'event_id'), name='unique_subscription_webhook_event'),
+ ),
+ migrations.AddField(
+ model_name='usageledgerentry',
+ name='entitlement',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='usage_entries', to='subscriptions.subscriptionentitlement'),
+ ),
+ migrations.AddField(
+ model_name='usageledgerentry',
+ name='server_session',
+ field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='usage_entry', to='subscriptions.serversession'),
+ ),
+ migrations.AddField(
+ model_name='usageledgerentry',
+ name='user',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='premium_usage_entries', to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddField(
+ model_name='subscriptionentitlement',
+ name='plan',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='entitlements', to='subscriptions.subscriptionplan'),
+ ),
+ migrations.AddField(
+ model_name='subscriptionentitlement',
+ name='user',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscription_entitlements', to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddField(
+ model_name='serversession',
+ name='entitlement',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='server_sessions', to='subscriptions.subscriptionentitlement'),
+ ),
+ migrations.AddField(
+ model_name='serversession',
+ name='user',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='premium_server_sessions', to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddIndex(
+ model_name='usageledgerentry',
+ index=models.Index(fields=['user', 'billing_period_start', 'billing_period_end'], name='subscriptio_user_id_f59e07_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='subscriptionentitlement',
+ index=models.Index(fields=['user', 'status'], name='subscriptio_user_id_f554ab_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='subscriptionentitlement',
+ index=models.Index(fields=['provider', 'provider_subscription_id'], name='subscriptio_provide_834861_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='subscriptionentitlement',
+ index=models.Index(fields=['current_period_end'], name='subscriptio_current_5d9c85_idx'),
+ ),
+ migrations.AddConstraint(
+ model_name='subscriptionentitlement',
+ constraint=models.UniqueConstraint(condition=models.Q(('provider_subscription_id', ''), _negated=True), fields=('provider', 'provider_subscription_id'), name='unique_subscription_provider_subscription'),
+ ),
+ migrations.AddIndex(
+ model_name='serversession',
+ index=models.Index(fields=['user', 'status'], name='subscriptio_user_id_27cd51_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='serversession',
+ index=models.Index(fields=['last_heartbeat_at'], name='subscriptio_last_he_9d0fbb_idx'),
+ ),
+ ]
diff --git a/subscriptions/migrations/0002_seed_default_plans.py b/subscriptions/migrations/0002_seed_default_plans.py
new file mode 100644
index 0000000..3b3827f
--- /dev/null
+++ b/subscriptions/migrations/0002_seed_default_plans.py
@@ -0,0 +1,47 @@
+from django.db import migrations
+
+
+def seed_plans(apps, schema_editor):
+ SubscriptionPlan = apps.get_model('subscriptions', 'SubscriptionPlan')
+ defaults = [
+ {
+ 'tier': 'supporter',
+ 'name': 'Supporter',
+ 'description': 'Premium Discord role and light casual server hosting.',
+ 'benefit_lines': 'Premium Discord role\nLaunch from Discord bot or website',
+ 'monthly_price_usd': 5,
+ 'monthly_server_minutes': 360,
+ 'max_session_minutes': 120,
+ 'max_concurrent_servers': 1,
+ 'display_order': 10,
+ 'entitlement_priority': 10,
+ },
+ {
+ 'tier': 'host',
+ 'name': 'Host',
+ 'description': 'More casual server hours and longer hosting sessions.',
+ 'benefit_lines': 'Higher premium Discord role\nSaved casual presets / faster relaunch flow',
+ 'monthly_price_usd': 12,
+ 'monthly_server_minutes': 1500,
+ 'max_session_minutes': 240,
+ 'max_concurrent_servers': 1,
+ 'display_order': 20,
+ 'entitlement_priority': 20,
+ },
+ ]
+ for plan in defaults:
+ SubscriptionPlan.objects.update_or_create(
+ tier=plan['tier'],
+ defaults=plan,
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('subscriptions', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RunPython(seed_plans, migrations.RunPython.noop),
+ ]
diff --git a/subscriptions/migrations/__init__.py b/subscriptions/migrations/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/subscriptions/migrations/__init__.py
@@ -0,0 +1 @@
+
diff --git a/subscriptions/models.py b/subscriptions/models.py
new file mode 100644
index 0000000..6b32c64
--- /dev/null
+++ b/subscriptions/models.py
@@ -0,0 +1,252 @@
+import math
+import uuid
+from datetime import timedelta
+
+from django.conf import settings
+from django.db import models
+from django.db.models import Q
+from django.utils import timezone
+
+
+class SubscriptionPlan(models.Model):
+ tier = models.SlugField(
+ max_length=64,
+ unique=True,
+ help_text='Stable plan identifier used in checkout URLs, Polar metadata, and API responses.',
+ )
+ name = models.CharField(max_length=80)
+ description = models.TextField(blank=True)
+ benefit_lines = models.TextField(
+ blank=True,
+ help_text='Optional website benefit copy, one benefit per line.',
+ )
+ monthly_price_usd = models.PositiveIntegerField()
+ monthly_server_minutes = models.PositiveIntegerField()
+ max_session_minutes = models.PositiveIntegerField()
+ max_concurrent_servers = models.PositiveIntegerField(default=1)
+ polar_product_id = models.CharField(max_length=128, blank=True)
+ discord_role_id = models.CharField(max_length=64, blank=True)
+ entitlement_priority = models.IntegerField(
+ default=0,
+ help_text='Higher priority wins when a user has multiple active entitlements.',
+ )
+ display_order = models.PositiveIntegerField(default=0)
+ is_active = models.BooleanField(default=True)
+
+ class Meta:
+ ordering = ['display_order', 'monthly_price_usd', 'name']
+
+ def __str__(self):
+ return self.name
+
+ @property
+ def benefits(self):
+ return [line.strip() for line in self.benefit_lines.splitlines() if line.strip()]
+
+
+class SubscriptionEntitlement(models.Model):
+ PROVIDER_POLAR = 'polar'
+ PROVIDER_DISCORD = 'discord_server_subscription'
+ PROVIDER_GITHUB = 'github_sponsors'
+ PROVIDER_COMP = 'comp'
+
+ STATUS_ACTIVE = 'active'
+ STATUS_TRIALING = 'trialing'
+ STATUS_PAST_DUE = 'past_due'
+ STATUS_CANCELED = 'canceled'
+ STATUS_EXPIRED = 'expired'
+ STATUS_INACTIVE = 'inactive'
+
+ PROVIDER_CHOICES = [
+ (PROVIDER_POLAR, 'Polar'),
+ (PROVIDER_DISCORD, 'Discord Server Subscription'),
+ (PROVIDER_GITHUB, 'GitHub Sponsors'),
+ (PROVIDER_COMP, 'Comp'),
+ ]
+
+ STATUS_CHOICES = [
+ (STATUS_ACTIVE, 'Active'),
+ (STATUS_TRIALING, 'Trialing'),
+ (STATUS_PAST_DUE, 'Past Due'),
+ (STATUS_CANCELED, 'Canceled'),
+ (STATUS_EXPIRED, 'Expired'),
+ (STATUS_INACTIVE, 'Inactive'),
+ ]
+
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='subscription_entitlements')
+ plan = models.ForeignKey(SubscriptionPlan, on_delete=models.PROTECT, related_name='entitlements')
+ provider = models.CharField(max_length=40, choices=PROVIDER_CHOICES)
+ provider_customer_id = models.CharField(max_length=128, blank=True)
+ provider_subscription_id = models.CharField(max_length=128, blank=True)
+ status = models.CharField(max_length=24, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
+ current_period_start = models.DateTimeField(default=timezone.now)
+ current_period_end = models.DateTimeField()
+ cancel_at_period_end = models.BooleanField(default=False)
+ latest_event_id = models.CharField(max_length=128, blank=True)
+ metadata = models.JSONField(default=dict, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ indexes = [
+ models.Index(fields=['user', 'status']),
+ models.Index(fields=['provider', 'provider_subscription_id']),
+ models.Index(fields=['current_period_end']),
+ ]
+ constraints = [
+ models.UniqueConstraint(
+ fields=['provider', 'provider_subscription_id'],
+ condition=~Q(provider_subscription_id=''),
+ name='unique_subscription_provider_subscription',
+ ),
+ ]
+
+ def __str__(self):
+ return f'{self.user} - {self.plan.name} ({self.status})'
+
+ @property
+ def is_active(self) -> bool:
+ return self.status in {self.STATUS_ACTIVE, self.STATUS_TRIALING} and self.current_period_end > timezone.now()
+
+ @property
+ def monthly_server_minutes(self) -> int:
+ return self.plan.monthly_server_minutes
+
+ @property
+ def max_session_minutes(self) -> int:
+ return self.plan.max_session_minutes
+
+ @property
+ def max_concurrent_servers(self) -> int:
+ return self.plan.max_concurrent_servers
+
+
+class ServerSession(models.Model):
+ STATUS_PENDING = 'pending'
+ STATUS_LAUNCHING = 'launching'
+ STATUS_RUNNING = 'running'
+ STATUS_STOPPING = 'stopping'
+ STATUS_STOPPED = 'stopped'
+ STATUS_FAILED = 'failed'
+ STATUS_EXPIRED = 'expired'
+ STATUS_KILLED = 'killed'
+
+ STATUS_CHOICES = [
+ (STATUS_PENDING, 'Pending'),
+ (STATUS_LAUNCHING, 'Launching'),
+ (STATUS_RUNNING, 'Running'),
+ (STATUS_STOPPING, 'Stopping'),
+ (STATUS_STOPPED, 'Stopped'),
+ (STATUS_FAILED, 'Failed'),
+ (STATUS_EXPIRED, 'Expired'),
+ (STATUS_KILLED, 'Killed'),
+ ]
+
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='premium_server_sessions')
+ entitlement = models.ForeignKey(SubscriptionEntitlement, on_delete=models.PROTECT, related_name='server_sessions')
+ status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_RUNNING)
+ requested_minutes = models.PositiveIntegerField()
+ allocated_minutes = models.PositiveIntegerField()
+ game = models.CharField(max_length=80, blank=True)
+ game_code = models.CharField(max_length=32, blank=True)
+ comment = models.CharField(max_length=120, blank=True)
+ server_identifier = models.CharField(max_length=128, blank=True)
+ orchestrator_session_id = models.CharField(max_length=128, blank=True)
+ orchestrator_state = models.CharField(max_length=64, blank=True)
+ host = models.CharField(max_length=255, blank=True)
+ port = models.PositiveIntegerField(null=True, blank=True)
+ server_password = models.CharField(max_length=80, blank=True)
+ ready_at = models.DateTimeField(null=True, blank=True)
+ orchestrator_stopped_at = models.DateTimeField(null=True, blank=True)
+ launch_error_code = models.CharField(max_length=80, blank=True)
+ launch_error_message = models.TextField(blank=True)
+ launch_context = models.JSONField(default=dict, blank=True)
+ started_at = models.DateTimeField(default=timezone.now)
+ last_heartbeat_at = models.DateTimeField(default=timezone.now)
+ stopped_at = models.DateTimeField(null=True, blank=True)
+ stop_reason = models.CharField(max_length=80, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ['-started_at']
+ indexes = [
+ models.Index(fields=['user', 'status']),
+ models.Index(fields=['last_heartbeat_at']),
+ ]
+
+ def __str__(self):
+ return f'{self.user} - {self.status} - {self.started_at}'
+
+ @property
+ def is_open(self) -> bool:
+ return self.status in {
+ self.STATUS_PENDING,
+ self.STATUS_LAUNCHING,
+ self.STATUS_RUNNING,
+ self.STATUS_STOPPING,
+ }
+
+ def rounded_runtime_minutes(self, stopped_at=None) -> int:
+ end_time = stopped_at or self.stopped_at or timezone.now()
+ start_time = self.ready_at or self.started_at
+ elapsed = max((end_time - start_time).total_seconds(), 0)
+ rounded = int(math.ceil(elapsed / 300.0) * 5)
+ return min(max(rounded, 5), self.allocated_minutes)
+
+ def expires_at(self):
+ return (self.ready_at or self.started_at) + timedelta(minutes=self.allocated_minutes)
+
+
+class UsageLedgerEntry(models.Model):
+ REASON_SERVER_SESSION = 'server_session'
+ REASON_ADMIN_ADJUSTMENT = 'admin_adjustment'
+
+ REASON_CHOICES = [
+ (REASON_SERVER_SESSION, 'Server Session'),
+ (REASON_ADMIN_ADJUSTMENT, 'Admin Adjustment'),
+ ]
+
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='premium_usage_entries')
+ entitlement = models.ForeignKey(SubscriptionEntitlement, on_delete=models.CASCADE, related_name='usage_entries')
+ server_session = models.OneToOneField(ServerSession, on_delete=models.SET_NULL, related_name='usage_entry', null=True, blank=True)
+ billing_period_start = models.DateTimeField()
+ billing_period_end = models.DateTimeField()
+ minutes = models.IntegerField()
+ reason = models.CharField(max_length=32, choices=REASON_CHOICES)
+ metadata = models.JSONField(default=dict, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ['-created_at']
+ indexes = [
+ models.Index(fields=['user', 'billing_period_start', 'billing_period_end']),
+ ]
+
+ def __str__(self):
+ return f'{self.user} - {self.minutes} minutes - {self.reason}'
+
+
+class WebhookEvent(models.Model):
+ STATUS_PROCESSED = 'processed'
+ STATUS_SKIPPED = 'skipped'
+ STATUS_FAILED = 'failed'
+
+ provider = models.CharField(max_length=40)
+ event_id = models.CharField(max_length=128)
+ event_type = models.CharField(max_length=120)
+ payload = models.JSONField(default=dict)
+ status = models.CharField(max_length=16, default=STATUS_PROCESSED)
+ error = models.TextField(blank=True)
+ received_at = models.DateTimeField(auto_now_add=True)
+ processed_at = models.DateTimeField(null=True, blank=True)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['provider', 'event_id'], name='unique_subscription_webhook_event'),
+ ]
+ ordering = ['-received_at']
+
+ def __str__(self):
+ return f'{self.provider}:{self.event_type}:{self.event_id}'
diff --git a/subscriptions/orchestrator.py b/subscriptions/orchestrator.py
new file mode 100644
index 0000000..aeaed72
--- /dev/null
+++ b/subscriptions/orchestrator.py
@@ -0,0 +1,87 @@
+import requests
+from django.conf import settings
+
+
+class OrchestratorError(ValueError):
+ def __init__(self, message, status_code=None, code='ORCHESTRATOR_ERROR', payload=None):
+ super().__init__(message)
+ self.status_code = status_code
+ self.code = code
+ self.payload = payload or {}
+
+
+def is_configured():
+ return bool(getattr(settings, 'ORCHESTRATOR_API_BASE_URL', '') and getattr(settings, 'ORCHESTRATOR_API_TOKEN', ''))
+
+
+def _base_url():
+ return getattr(settings, 'ORCHESTRATOR_API_BASE_URL', '').rstrip('/')
+
+
+def _timeout():
+ return float(getattr(settings, 'ORCHESTRATOR_REQUEST_TIMEOUT_SECONDS', 10))
+
+
+def _request(method, path, **kwargs):
+ if not is_configured():
+ raise OrchestratorError('The server orchestrator is not configured.', code='ORCHESTRATOR_NOT_CONFIGURED')
+
+ headers = kwargs.pop('headers', {})
+ headers['Authorization'] = f'Bearer {settings.ORCHESTRATOR_API_TOKEN}'
+ headers.setdefault('Accept', 'application/json')
+ if 'json' in kwargs:
+ headers.setdefault('Content-Type', 'application/json')
+
+ try:
+ response = requests.request(
+ method,
+ f'{_base_url()}{path}',
+ headers=headers,
+ timeout=_timeout(),
+ **kwargs,
+ )
+ except requests.RequestException as exc:
+ raise OrchestratorError('The server orchestrator could not be reached.', code='ORCHESTRATOR_UNAVAILABLE') from exc
+
+ try:
+ payload = response.json() if response.content else {}
+ except ValueError:
+ payload = {}
+
+ if response.status_code >= 400:
+ error = payload.get('error') if isinstance(payload, dict) else None
+ message = (error or {}).get('message') or 'The server orchestrator rejected the request.'
+ code = (error or {}).get('code') or 'ORCHESTRATOR_REJECTED_REQUEST'
+ raise OrchestratorError(message, status_code=response.status_code, code=code, payload=payload)
+
+ return payload
+
+
+def get_casual_games():
+ payload = _request('GET', '/v1/games', params={'kind': 'casual'})
+ return payload.get('games', [])
+
+
+def launch_casual(session, *, game_code, comment, password='', launch_source='website', actor=None):
+ payload = {
+ 'kind': 'casual',
+ 'gameCode': str(game_code),
+ 'websiteServerSessionId': str(session.id),
+ 'requestedMinutes': session.requested_minutes,
+ 'maxMinutes': session.allocated_minutes,
+ 'comment': comment,
+ 'launchSource': launch_source,
+ }
+ if password:
+ payload['password'] = password
+ if actor:
+ payload['actor'] = actor
+
+ return _request('POST', '/v1/servers', json=payload)
+
+
+def stop_server(orchestrator_session_id, *, reason='website_stop', actor=None):
+ payload = {'reason': reason}
+ if actor:
+ payload['actor'] = actor
+ return _request('POST', f'/v1/servers/{orchestrator_session_id}/stop', json=payload)
diff --git a/subscriptions/services.py b/subscriptions/services.py
new file mode 100644
index 0000000..3ec4f67
--- /dev/null
+++ b/subscriptions/services.py
@@ -0,0 +1,695 @@
+import base64
+import hashlib
+import hmac
+import json
+from datetime import timedelta, timezone as datetime_timezone
+
+import requests
+from django.conf import settings
+from django.db import transaction
+from django.utils import timezone
+from django.utils.dateparse import parse_datetime
+
+from discordoauth2.models import User
+from .models import (
+ ServerSession,
+ SubscriptionEntitlement,
+ SubscriptionPlan,
+ UsageLedgerEntry,
+ WebhookEvent,
+)
+from . import orchestrator
+
+
+ACTIVE_STATUSES = {
+ SubscriptionEntitlement.STATUS_ACTIVE,
+ SubscriptionEntitlement.STATUS_TRIALING,
+}
+
+OPEN_SESSION_STATUSES = {
+ ServerSession.STATUS_PENDING,
+ ServerSession.STATUS_LAUNCHING,
+ ServerSession.STATUS_RUNNING,
+ ServerSession.STATUS_STOPPING,
+}
+
+TERMINAL_SESSION_STATUSES = {
+ ServerSession.STATUS_STOPPED,
+ ServerSession.STATUS_FAILED,
+ ServerSession.STATUS_EXPIRED,
+ ServerSession.STATUS_KILLED,
+}
+
+POLAR_STATUS_MAP = {
+ 'active': SubscriptionEntitlement.STATUS_ACTIVE,
+ 'trialing': SubscriptionEntitlement.STATUS_TRIALING,
+ 'past_due': SubscriptionEntitlement.STATUS_PAST_DUE,
+ 'unpaid': SubscriptionEntitlement.STATUS_PAST_DUE,
+ 'canceled': SubscriptionEntitlement.STATUS_CANCELED,
+ 'cancelled': SubscriptionEntitlement.STATUS_CANCELED,
+ 'revoked': SubscriptionEntitlement.STATUS_EXPIRED,
+ 'ended': SubscriptionEntitlement.STATUS_EXPIRED,
+ 'expired': SubscriptionEntitlement.STATUS_EXPIRED,
+ 'incomplete': SubscriptionEntitlement.STATUS_INACTIVE,
+}
+
+
+class WebhookVerificationError(ValueError):
+ pass
+
+
+class SubscriptionError(ValueError):
+ def __init__(self, message, status_code=403, code='SUBSCRIPTION_ERROR'):
+ super().__init__(message)
+ self.status_code = status_code
+ self.code = code
+
+
+def get_active_entitlement(user):
+ now = timezone.now()
+ entitlements = SubscriptionEntitlement.objects.filter(
+ user=user,
+ status__in=ACTIVE_STATUSES,
+ current_period_end__gt=now,
+ ).select_related('plan').order_by(
+ '-plan__entitlement_priority',
+ '-plan__monthly_price_usd',
+ '-plan__monthly_server_minutes',
+ 'plan__display_order',
+ )
+ return entitlements.first()
+
+
+def used_minutes(entitlement):
+ return UsageLedgerEntry.objects.filter(
+ entitlement=entitlement,
+ billing_period_start=entitlement.current_period_start,
+ billing_period_end=entitlement.current_period_end,
+ ).aggregate(total=models_sum('minutes'))['total'] or 0
+
+
+def models_sum(field_name):
+ from django.db.models import Sum
+
+ return Sum(field_name)
+
+
+def remaining_minutes(entitlement):
+ return max(entitlement.monthly_server_minutes - used_minutes(entitlement), 0)
+
+
+def entitlement_payload(user):
+ entitlement = get_active_entitlement(user)
+ if entitlement is None:
+ return {
+ 'active': False,
+ 'tier': None,
+ 'plan': None,
+ 'period_start': None,
+ 'period_end': None,
+ 'monthly_server_minutes': 0,
+ 'used_server_minutes': 0,
+ 'remaining_server_minutes': 0,
+ 'max_session_minutes': 0,
+ 'active_session': None,
+ }
+
+ active_session = ServerSession.objects.filter(user=user, status__in=OPEN_SESSION_STATUSES).first()
+ used = used_minutes(entitlement)
+ return {
+ 'active': True,
+ 'tier': entitlement.plan.tier,
+ 'plan': entitlement.plan.name,
+ 'period_start': entitlement.current_period_start,
+ 'period_end': entitlement.current_period_end,
+ 'monthly_server_minutes': entitlement.monthly_server_minutes,
+ 'used_server_minutes': used,
+ 'remaining_server_minutes': max(entitlement.monthly_server_minutes - used, 0),
+ 'max_session_minutes': entitlement.max_session_minutes,
+ 'max_concurrent_servers': entitlement.max_concurrent_servers,
+ 'provider': entitlement.provider,
+ 'cancel_at_period_end': entitlement.cancel_at_period_end,
+ 'active_session': serialize_session(active_session) if active_session else None,
+ }
+
+
+def serialize_session(session):
+ if session is None:
+ return None
+ return {
+ 'id': str(session.id),
+ 'status': session.status,
+ 'game': session.game,
+ 'game_code': session.game_code,
+ 'comment': session.comment,
+ 'server_identifier': session.server_identifier,
+ 'orchestrator_session_id': session.orchestrator_session_id,
+ 'orchestrator_state': session.orchestrator_state,
+ 'host': session.host,
+ 'port': session.port,
+ 'password': session.server_password,
+ 'requested_minutes': session.requested_minutes,
+ 'allocated_minutes': session.allocated_minutes,
+ 'started_at': session.started_at,
+ 'ready_at': session.ready_at,
+ 'last_heartbeat_at': session.last_heartbeat_at,
+ 'stopped_at': session.stopped_at,
+ 'stop_reason': session.stop_reason,
+ 'launch_error_code': session.launch_error_code,
+ 'launch_error_message': session.launch_error_message,
+ 'expires_at': session.expires_at(),
+ }
+
+
+@transaction.atomic
+def start_server_session(user, requested_minutes=None, game='', server_identifier='', launch_context=None, status=ServerSession.STATUS_RUNNING):
+ entitlement = get_active_entitlement(user)
+ if entitlement is None:
+ raise SubscriptionError('An active premium subscription is required to launch a casual server.')
+
+ running_sessions = ServerSession.objects.select_for_update().filter(
+ user=user,
+ status__in=OPEN_SESSION_STATUSES,
+ ).count()
+ if running_sessions >= entitlement.max_concurrent_servers:
+ if entitlement.max_concurrent_servers == 1:
+ raise SubscriptionError('You already have a casual server session running.')
+ raise SubscriptionError('You have reached your plan limit for concurrent casual servers.')
+
+ remaining = remaining_minutes(entitlement)
+ if remaining <= 0:
+ raise SubscriptionError('You have used all of your casual server hours for this billing period.')
+
+ requested = requested_minutes or entitlement.max_session_minutes
+ try:
+ requested = int(requested)
+ except (TypeError, ValueError) as exc:
+ raise SubscriptionError('requested_minutes must be an integer.', status_code=400, code='INVALID_REQUESTED_MINUTES') from exc
+ requested = max(requested, 5)
+ allocated = min(requested, entitlement.max_session_minutes, remaining)
+
+ session = ServerSession.objects.create(
+ user=user,
+ entitlement=entitlement,
+ status=status,
+ requested_minutes=requested,
+ allocated_minutes=allocated,
+ game=game or '',
+ server_identifier=server_identifier or '',
+ launch_context=launch_context or {},
+ )
+ return session
+
+
+def _parse_datetime(value):
+ if not value:
+ return None
+ parsed = parse_datetime(value) if isinstance(value, str) else value
+ if parsed and timezone.is_naive(parsed):
+ return timezone.make_aware(parsed, datetime_timezone.utc)
+ return parsed
+
+
+def _actor_for_user(user, actor_type='website_user'):
+ display_name = getattr(user, 'display_name', '') or getattr(user, 'username', '') or str(user.id)
+ return {
+ 'type': actor_type,
+ 'discordUserId': str(user.id),
+ 'displayName': display_name,
+ }
+
+
+def _apply_orchestrator_payload(session, payload):
+ details = payload.get('session') or payload.get('server') or payload
+ if not isinstance(details, dict):
+ return
+
+ orchestrator_session_id = details.get('id') or details.get('sessionId') or details.get('orchestratorSessionId')
+ if orchestrator_session_id:
+ session.orchestrator_session_id = str(orchestrator_session_id)
+ session.server_identifier = str(orchestrator_session_id)
+
+ state = details.get('state') or details.get('status') or details.get('orchestratorState')
+ if state:
+ session.orchestrator_state = str(state)
+
+ if details.get('host'):
+ session.host = str(details['host'])
+ if details.get('port') is not None:
+ session.port = int(details['port'])
+ if details.get('password'):
+ session.server_password = str(details['password'])
+
+
+@transaction.atomic
+def reserve_server_session(user, requested_minutes=None, game='', game_code='', comment='', password='', launch_context=None):
+ session = start_server_session(
+ user,
+ requested_minutes=requested_minutes,
+ game=game,
+ launch_context=launch_context,
+ status=ServerSession.STATUS_PENDING,
+ )
+ session.game_code = str(game_code or '')
+ session.comment = (comment or '')[:120]
+ session.server_password = (password or '')[:80]
+ session.save(update_fields=['game_code', 'comment', 'server_password', 'updated_at'])
+ return session
+
+
+def launch_casual_server(user, requested_minutes=None, game='', game_code='', comment='', password='', launch_source='website', launch_context=None):
+ session = reserve_server_session(
+ user,
+ requested_minutes=requested_minutes,
+ game=game,
+ game_code=game_code,
+ comment=comment,
+ password=password,
+ launch_context=launch_context,
+ )
+
+ try:
+ payload = orchestrator.launch_casual(
+ session,
+ game_code=session.game_code,
+ comment=session.comment,
+ password=session.server_password,
+ launch_source=launch_source,
+ actor=_actor_for_user(user, actor_type='discord_bot' if launch_source == 'discord_bot' else 'website_user'),
+ )
+ except orchestrator.OrchestratorError as exc:
+ mark_server_session_failed(session, exc.code, str(exc))
+ raise SubscriptionError(str(exc), status_code=503, code=exc.code) from exc
+
+ session = ServerSession.objects.get(pk=session.pk)
+ _apply_orchestrator_payload(session, payload)
+ session.status = ServerSession.STATUS_LAUNCHING
+ session.save(update_fields=[
+ 'status',
+ 'orchestrator_session_id',
+ 'orchestrator_state',
+ 'host',
+ 'port',
+ 'server_password',
+ 'server_identifier',
+ 'updated_at',
+ ])
+ return session
+
+
+@transaction.atomic
+def mark_server_session_failed(session, code='', message=''):
+ session = ServerSession.objects.select_for_update().get(pk=session.pk)
+ if session.status in TERMINAL_SESSION_STATUSES:
+ return session
+ now = timezone.now()
+ session.status = ServerSession.STATUS_FAILED
+ session.stopped_at = now
+ session.stop_reason = 'launch_failed'
+ session.launch_error_code = code or ''
+ session.launch_error_message = message or ''
+ session.save(update_fields=[
+ 'status',
+ 'stopped_at',
+ 'stop_reason',
+ 'launch_error_code',
+ 'launch_error_message',
+ 'updated_at',
+ ])
+ return session
+
+
+@transaction.atomic
+def stop_server_session(session, reason='stopped'):
+ session = ServerSession.objects.select_for_update().get(pk=session.pk)
+ if session.status not in OPEN_SESSION_STATUSES:
+ return session
+
+ stopped_at = timezone.now()
+ billed_minutes = session.rounded_runtime_minutes(stopped_at=stopped_at)
+ session.status = ServerSession.STATUS_EXPIRED if reason == ServerSession.STATUS_EXPIRED else ServerSession.STATUS_STOPPED
+ session.stopped_at = stopped_at
+ session.stop_reason = reason or 'stopped'
+ session.save(update_fields=['status', 'stopped_at', 'stop_reason', 'updated_at'])
+
+ UsageLedgerEntry.objects.get_or_create(
+ server_session=session,
+ defaults={
+ 'user': session.user,
+ 'entitlement': session.entitlement,
+ 'billing_period_start': session.entitlement.current_period_start,
+ 'billing_period_end': session.entitlement.current_period_end,
+ 'minutes': billed_minutes,
+ 'reason': UsageLedgerEntry.REASON_SERVER_SESSION,
+ 'metadata': {'stop_reason': session.stop_reason},
+ },
+ )
+ return session
+
+
+def _rounded_runtime_minutes_from_seconds(runtime_seconds, allocated_minutes):
+ elapsed = max(int(runtime_seconds or 0), 0)
+ if elapsed <= 0:
+ return 0
+ rounded = int((elapsed + 299) // 300 * 5)
+ return min(max(rounded, 5), allocated_minutes)
+
+
+def _record_orchestrator_usage(session, stopped_at, runtime_seconds=None):
+ if runtime_seconds is not None:
+ billed_minutes = _rounded_runtime_minutes_from_seconds(runtime_seconds, session.allocated_minutes)
+ elif session.ready_at:
+ billed_minutes = session.rounded_runtime_minutes(stopped_at=stopped_at)
+ else:
+ billed_minutes = 0
+
+ if billed_minutes <= 0:
+ return None
+
+ usage, _created = UsageLedgerEntry.objects.get_or_create(
+ server_session=session,
+ defaults={
+ 'user': session.user,
+ 'entitlement': session.entitlement,
+ 'billing_period_start': session.entitlement.current_period_start,
+ 'billing_period_end': session.entitlement.current_period_end,
+ 'minutes': billed_minutes,
+ 'reason': UsageLedgerEntry.REASON_SERVER_SESSION,
+ 'metadata': {'stop_reason': session.stop_reason, 'source': 'orchestrator'},
+ },
+ )
+ return usage
+
+
+@transaction.atomic
+def process_orchestrator_event(session_id, payload):
+ event_id = payload.get('event_id')
+ event_type = payload.get('event')
+ if not event_id or not event_type:
+ raise SubscriptionError('Orchestrator events require event_id and event.')
+
+ event, created = WebhookEvent.objects.get_or_create(
+ provider='orchestrator',
+ event_id=event_id,
+ defaults={
+ 'event_type': event_type,
+ 'payload': payload,
+ 'status': WebhookEvent.STATUS_PROCESSED,
+ 'processed_at': timezone.now(),
+ },
+ )
+ if not created:
+ return event
+
+ session = ServerSession.objects.select_for_update().get(pk=session_id)
+ occurred_at = _parse_datetime(payload.get('occurred_at')) or timezone.now()
+
+ if payload.get('orchestrator_session_id'):
+ session.orchestrator_session_id = str(payload['orchestrator_session_id'])
+ session.server_identifier = str(payload['orchestrator_session_id'])
+ if payload.get('orchestrator_state'):
+ session.orchestrator_state = str(payload['orchestrator_state'])
+ if payload.get('host'):
+ session.host = str(payload['host'])
+ if payload.get('port') is not None:
+ session.port = int(payload['port'])
+ if payload.get('password'):
+ session.server_password = str(payload['password'])
+
+ if event_type == 'launch_accepted':
+ session.status = ServerSession.STATUS_LAUNCHING
+ elif event_type == 'ready':
+ session.status = ServerSession.STATUS_RUNNING
+ session.ready_at = _parse_datetime(payload.get('ready_at')) or occurred_at
+ session.last_heartbeat_at = occurred_at
+ elif event_type == 'heartbeat':
+ if session.status not in TERMINAL_SESSION_STATUSES:
+ session.status = ServerSession.STATUS_RUNNING
+ session.last_heartbeat_at = occurred_at
+ elif event_type == 'stopping':
+ if session.status not in TERMINAL_SESSION_STATUSES:
+ session.status = ServerSession.STATUS_STOPPING
+ session.stop_reason = payload.get('reason') or 'stopping'
+ elif event_type in {'stopped', 'exited', 'killed'}:
+ stopped_at = _parse_datetime(payload.get('stopped_at')) or occurred_at
+ session.status = ServerSession.STATUS_KILLED if event_type == 'killed' else ServerSession.STATUS_STOPPED
+ session.stopped_at = stopped_at
+ session.orchestrator_stopped_at = stopped_at
+ session.stop_reason = payload.get('reason') or event_type
+ runtime_seconds = payload.get('runtime_seconds')
+ _record_orchestrator_usage(session, stopped_at, runtime_seconds=runtime_seconds)
+ elif event_type == 'launch_failed':
+ session.status = ServerSession.STATUS_FAILED
+ session.stopped_at = occurred_at
+ session.stop_reason = 'launch_failed'
+ session.launch_error_code = payload.get('error_code') or ''
+ session.launch_error_message = payload.get('error_message') or ''
+
+ session.save(update_fields=[
+ 'status',
+ 'orchestrator_session_id',
+ 'orchestrator_state',
+ 'host',
+ 'port',
+ 'server_password',
+ 'server_identifier',
+ 'ready_at',
+ 'last_heartbeat_at',
+ 'stopped_at',
+ 'orchestrator_stopped_at',
+ 'stop_reason',
+ 'launch_error_code',
+ 'launch_error_message',
+ 'updated_at',
+ ])
+ return event
+
+
+def request_stop_server_session(session, reason='stopped', actor=None):
+ session = ServerSession.objects.get(pk=session.pk)
+ if session.status in TERMINAL_SESSION_STATUSES:
+ return session
+
+ if session.orchestrator_session_id:
+ try:
+ orchestrator.stop_server(session.orchestrator_session_id, reason=reason, actor=actor)
+ except orchestrator.OrchestratorError as exc:
+ raise SubscriptionError(str(exc), status_code=503, code=exc.code) from exc
+ session.status = ServerSession.STATUS_STOPPING
+ session.stop_reason = reason or 'stopping'
+ session.save(update_fields=['status', 'stop_reason', 'updated_at'])
+ return session
+
+ return stop_server_session(session, reason=reason)
+
+
+def expire_stale_sessions(max_heartbeat_age_minutes=10):
+ cutoff = timezone.now() - timedelta(minutes=max_heartbeat_age_minutes)
+ stale_sessions = ServerSession.objects.filter(
+ status=ServerSession.STATUS_RUNNING,
+ last_heartbeat_at__lt=cutoff,
+ )
+ expired = []
+ for session in stale_sessions:
+ expired.append(stop_server_session(session, reason=ServerSession.STATUS_EXPIRED))
+ return expired
+
+
+def verify_standard_webhook_signature(secret, body, webhook_id, webhook_timestamp, webhook_signature):
+ if not secret:
+ raise WebhookVerificationError('Polar webhook secret is not configured.')
+ if not webhook_id or not webhook_timestamp or not webhook_signature:
+ raise WebhookVerificationError('Missing webhook signature headers.')
+
+ try:
+ timestamp = int(webhook_timestamp)
+ except (TypeError, ValueError) as exc:
+ raise WebhookVerificationError('Invalid webhook timestamp.') from exc
+
+ if abs(int(timezone.now().timestamp()) - timestamp) > 300:
+ raise WebhookVerificationError('Webhook timestamp is outside the replay window.')
+
+ signing_secret = secret
+ if signing_secret.startswith('whsec_'):
+ signing_secret = signing_secret[len('whsec_'):]
+ try:
+ secret_bytes = base64.b64decode(signing_secret, validate=True)
+ except Exception:
+ secret_bytes = secret.encode('utf-8')
+
+ signed_content = b'.'.join([
+ webhook_id.encode('utf-8'),
+ webhook_timestamp.encode('utf-8'),
+ body,
+ ])
+ digest = hmac.new(secret_bytes, signed_content, hashlib.sha256).digest()
+ expected = 'v1,' + base64.b64encode(digest).decode('utf-8')
+
+ signatures = webhook_signature.split(' ')
+ if not any(hmac.compare_digest(expected, signature) for signature in signatures):
+ raise WebhookVerificationError('Invalid webhook signature.')
+
+
+def process_polar_webhook(payload, event_id):
+ event_type = payload.get('type') or payload.get('event') or ''
+ data = payload.get('data') or {}
+
+ event, created = WebhookEvent.objects.get_or_create(
+ provider=SubscriptionEntitlement.PROVIDER_POLAR,
+ event_id=event_id,
+ defaults={
+ 'event_type': event_type,
+ 'payload': payload,
+ },
+ )
+ if not created:
+ return event
+
+ try:
+ if event_type.startswith('subscription.'):
+ _upsert_entitlement_from_polar_subscription(data, event_id)
+ event.status = WebhookEvent.STATUS_PROCESSED
+ else:
+ event.status = WebhookEvent.STATUS_SKIPPED
+ event.processed_at = timezone.now()
+ event.save(update_fields=['status', 'processed_at'])
+ except Exception as exc:
+ event.status = WebhookEvent.STATUS_FAILED
+ event.error = str(exc)
+ event.processed_at = timezone.now()
+ event.save(update_fields=['status', 'error', 'processed_at'])
+ raise
+ return event
+
+
+def create_polar_checkout_url(user, plan, customer_ip_address=''):
+ token = getattr(settings, 'POLAR_ACCESS_TOKEN', '')
+ if not token:
+ raise SubscriptionError('Polar access token is not configured.')
+ if not plan.polar_product_id:
+ raise SubscriptionError(f'Polar product ID is not configured for {plan.name}.')
+
+ payload = {
+ 'products': [plan.polar_product_id],
+ 'external_customer_id': str(user.id),
+ 'customer_email': user.email,
+ 'metadata': {
+ 'discord_user_id': str(user.id),
+ 'tier': plan.tier,
+ },
+ }
+ success_url = getattr(settings, 'POLAR_CHECKOUT_SUCCESS_URL', '')
+ if success_url:
+ payload['success_url'] = success_url
+ if customer_ip_address:
+ payload['customer_ip_address'] = customer_ip_address
+
+ response = requests.post(
+ f'{settings.POLAR_API_BASE_URL}/v1/checkouts',
+ headers={
+ 'Authorization': f'Bearer {token}',
+ 'Content-Type': 'application/json',
+ },
+ data=json.dumps(payload),
+ timeout=10,
+ )
+ if response.status_code >= 400:
+ raise SubscriptionError('Polar checkout could not be created.')
+ data = response.json()
+ checkout_url = data.get('url')
+ if not checkout_url:
+ raise SubscriptionError('Polar checkout response did not include a checkout URL.')
+ return checkout_url
+
+
+def _upsert_entitlement_from_polar_subscription(data, event_id):
+ user = _user_from_polar_data(data)
+ plan = _plan_from_polar_data(data)
+ if user is None or plan is None:
+ raise SubscriptionError('Polar subscription webhook did not include a known user and plan.')
+
+ subscription_id = str(data.get('id') or data.get('subscription_id') or '')
+ if not subscription_id:
+ raise SubscriptionError('Polar subscription webhook did not include a subscription ID.')
+
+ period_start = _parse_dt(
+ data.get('current_period_start')
+ or data.get('started_at')
+ or data.get('created_at')
+ ) or timezone.now()
+ period_end = _parse_dt(
+ data.get('current_period_end')
+ or data.get('ends_at')
+ or data.get('ended_at')
+ ) or (period_start + timedelta(days=31))
+ raw_status = str(data.get('status') or '').lower()
+ status = POLAR_STATUS_MAP.get(raw_status, SubscriptionEntitlement.STATUS_ACTIVE)
+
+ customer = data.get('customer') or {}
+ customer_id = str(data.get('customer_id') or customer.get('id') or '')
+
+ SubscriptionEntitlement.objects.update_or_create(
+ provider=SubscriptionEntitlement.PROVIDER_POLAR,
+ provider_subscription_id=subscription_id,
+ defaults={
+ 'user': user,
+ 'plan': plan,
+ 'provider_customer_id': customer_id,
+ 'status': status,
+ 'current_period_start': period_start,
+ 'current_period_end': period_end,
+ 'cancel_at_period_end': bool(data.get('cancel_at_period_end') or data.get('cancel_at_period') or False),
+ 'latest_event_id': event_id,
+ 'metadata': data,
+ },
+ )
+
+
+def _user_from_polar_data(data):
+ customer = data.get('customer') or {}
+ metadata = data.get('metadata') or {}
+ external_id = (
+ data.get('external_customer_id')
+ or data.get('customer_external_id')
+ or customer.get('external_id')
+ or metadata.get('discord_user_id')
+ )
+ if external_id is None:
+ return None
+ try:
+ return User.objects.get(id=int(external_id))
+ except (User.DoesNotExist, TypeError, ValueError, OverflowError):
+ return None
+
+
+def _plan_from_polar_data(data):
+ metadata = data.get('metadata') or {}
+ tier = metadata.get('tier')
+ if tier:
+ plan = SubscriptionPlan.objects.filter(tier=tier).first()
+ if plan:
+ return plan
+
+ product_id = (
+ data.get('product_id')
+ or (data.get('product') or {}).get('id')
+ or (data.get('products') or [{}])[0].get('id')
+ )
+ if product_id:
+ plan = SubscriptionPlan.objects.filter(polar_product_id=product_id).first()
+ if plan:
+ return plan
+ return None
+
+
+def _parse_dt(value):
+ if not value:
+ return None
+ if hasattr(value, 'tzinfo'):
+ return value
+ parsed = parse_datetime(str(value))
+ if parsed is None:
+ return None
+ if timezone.is_naive(parsed):
+ return timezone.make_aware(parsed, datetime_timezone.utc)
+ return parsed
diff --git a/subscriptions/templates/subscriptions/home.html b/subscriptions/templates/subscriptions/home.html
new file mode 100644
index 0000000..58dbaaa
--- /dev/null
+++ b/subscriptions/templates/subscriptions/home.html
@@ -0,0 +1,180 @@
+{% extends "home/base.html" %}
+
+{% block content %}
+
+
+
+
Premium
+
Casual server hosting for Second Robotics community members.
+
+ {% if customer_portal_url %}
+
Manage billing
+ {% endif %}
+
+
+ {% if messages %}
+ {% for message in messages %}
+
{{ message }}
+ {% endfor %}
+ {% endif %}
+
+
+
+
Your subscription
+ {% if entitlement.active %}
+
+ {{ entitlement.plan }}
+ {% if entitlement.cancel_at_period_end %}
+ Cancels at period end
+ {% else %}
+ Active
+ {% endif %}
+
+
+
+ {{ entitlement.remaining_server_minutes }} of {{ entitlement.monthly_server_minutes }} minutes remaining this billing period.
+
+ {% if entitlement.active_session %}
+
You have a casual server session active.
+ {% endif %}
+ {% else %}
+
No active premium subscription.
+ {% endif %}
+
+
+
+ {% if entitlement.active %}
+
+
+
Launch casual server
+ {% if not orchestrator_configured %}
+
Casual server launching is not configured yet.
+ {% elif games_error %}
+
{{ games_error }}
+ {% elif entitlement.active_session %}
+
Stop your active casual session before launching another one.
+ {% else %}
+
+ {% endif %}
+
+
+ {% endif %}
+
+
+ {% for plan in plans %}
+
+
+
+
{{ plan.name }}
+ {% if plan.description %}
+
{{ plan.description }}
+ {% endif %}
+
${{ plan.monthly_price_usd }}/month
+
+ {% for benefit in plan.benefits %}
+ - {{ benefit }}
+ {% endfor %}
+ - {{ plan.monthly_server_minutes }} casual server minutes per billing period
+ - {{ plan.max_session_minutes }} minute max casual server session
+ - {{ plan.max_concurrent_servers }} active casual server{{ plan.max_concurrent_servers|pluralize }} at a time
+
+
Subscribe with Polar
+
+
+
+ {% endfor %}
+
+
+
+
+
Recent casual sessions
+ {% if sessions %}
+
+
+
+
+ | Started |
+ Status |
+ Game |
+ Connection |
+ Allocated |
+ Stopped |
+ |
+
+
+
+ {% for session in sessions %}
+
+ | {{ session.started_at }} |
+ {{ session.status }} |
+ {{ session.game|default:"Casual" }} |
+
+ {% if session.host and session.port %}
+ {{ session.host }}:{{ session.port }}
+ {% if session.server_password %}
+ Password: {{ session.server_password }}
+ {% endif %}
+ {% else %}
+ -
+ {% endif %}
+ |
+ {{ session.allocated_minutes }} min |
+ {{ session.stopped_at|default:"-" }} |
+
+ {% if session.is_open %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
No casual server sessions yet.
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/subscriptions/tests.py b/subscriptions/tests.py
new file mode 100644
index 0000000..16df89d
--- /dev/null
+++ b/subscriptions/tests.py
@@ -0,0 +1,415 @@
+import base64
+import hashlib
+import hmac
+import json
+from datetime import timedelta
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from django.utils import timezone
+from rest_framework.authtoken.models import Token
+from rest_framework.test import APIClient
+
+from discordoauth2.models import User
+from subscriptions.models import ServerSession, SubscriptionEntitlement, SubscriptionPlan, UsageLedgerEntry, WebhookEvent
+from subscriptions.services import (
+ SubscriptionError,
+ expire_stale_sessions,
+ process_orchestrator_event,
+ start_server_session,
+ stop_server_session,
+ verify_standard_webhook_signature,
+)
+
+
+class MockResponse:
+ def __init__(self, status_code=200, payload=None):
+ self.status_code = status_code
+ self._payload = payload or {}
+ self.content = json.dumps(self._payload).encode('utf-8')
+
+ def json(self):
+ return self._payload
+
+
+def create_user(user_id=1001):
+ return User.objects.create(
+ id=user_id,
+ username=f'user{user_id}',
+ discriminator='0001',
+ avatar='https://cdn.discordapp.com/embed/avatars/0.png',
+ public_flags=0,
+ flags=0,
+ locale='en-US',
+ mfa_enabled=False,
+ email=f'user{user_id}@example.com',
+ verified=True,
+ )
+
+
+def create_entitlement(user, tier='supporter', minutes=360):
+ plan = SubscriptionPlan.objects.get(tier=tier)
+ if minutes != plan.monthly_server_minutes:
+ plan.monthly_server_minutes = minutes
+ plan.save(update_fields=['monthly_server_minutes'])
+ now = timezone.now()
+ return SubscriptionEntitlement.objects.create(
+ user=user,
+ plan=plan,
+ provider=SubscriptionEntitlement.PROVIDER_COMP,
+ provider_subscription_id=f'comp-{user.id}-{tier}',
+ status=SubscriptionEntitlement.STATUS_ACTIVE,
+ current_period_start=now,
+ current_period_end=now + timedelta(days=30),
+ )
+
+
+class SubscriptionServiceTests(TestCase):
+ def test_start_requires_active_entitlement(self):
+ user = create_user()
+
+ with self.assertRaises(SubscriptionError):
+ start_server_session(user)
+
+ def test_start_limits_session_to_plan_and_remaining_minutes(self):
+ user = create_user()
+ create_entitlement(user, tier='supporter', minutes=30)
+
+ session = start_server_session(user, requested_minutes=240, game='xRC')
+
+ self.assertEqual(session.allocated_minutes, 30)
+ self.assertEqual(session.requested_minutes, 240)
+ self.assertEqual(session.game, 'xRC')
+
+ def test_cannot_start_concurrent_session(self):
+ user = create_user()
+ create_entitlement(user)
+ start_server_session(user)
+
+ with self.assertRaises(SubscriptionError):
+ start_server_session(user)
+
+ def test_plan_can_allow_multiple_concurrent_sessions(self):
+ user = create_user()
+ plan = SubscriptionPlan.objects.get(tier='supporter')
+ plan.max_concurrent_servers = 2
+ plan.monthly_server_minutes = 360
+ plan.save(update_fields=['max_concurrent_servers', 'monthly_server_minutes'])
+ create_entitlement(user)
+
+ start_server_session(user, requested_minutes=30)
+ second = start_server_session(user, requested_minutes=30)
+
+ self.assertEqual(second.allocated_minutes, 30)
+
+ def test_highest_priority_entitlement_is_used(self):
+ user = create_user()
+ supporter = SubscriptionPlan.objects.get(tier='supporter')
+ host = SubscriptionPlan.objects.get(tier='host')
+ supporter.entitlement_priority = 100
+ supporter.monthly_server_minutes = 30
+ supporter.save(update_fields=['entitlement_priority', 'monthly_server_minutes'])
+ host.entitlement_priority = 10
+ host.monthly_server_minutes = 1500
+ host.save(update_fields=['entitlement_priority', 'monthly_server_minutes'])
+ create_entitlement(user, tier='host')
+ create_entitlement(user, tier='supporter', minutes=30)
+
+ session = start_server_session(user, requested_minutes=240)
+
+ self.assertEqual(session.entitlement.plan.tier, 'supporter')
+ self.assertEqual(session.allocated_minutes, 30)
+
+ def test_stop_records_rounded_usage_once(self):
+ user = create_user()
+ create_entitlement(user)
+ session = start_server_session(user)
+ session.started_at = timezone.now() - timedelta(minutes=6)
+ session.save(update_fields=['started_at'])
+
+ stop_server_session(session)
+ stop_server_session(session)
+
+ usage = UsageLedgerEntry.objects.get(server_session=session)
+ self.assertEqual(usage.minutes, 10)
+ self.assertEqual(UsageLedgerEntry.objects.count(), 1)
+
+ def test_exhausted_entitlement_rejects_start(self):
+ user = create_user()
+ entitlement = create_entitlement(user, minutes=30)
+ UsageLedgerEntry.objects.create(
+ user=user,
+ entitlement=entitlement,
+ billing_period_start=entitlement.current_period_start,
+ billing_period_end=entitlement.current_period_end,
+ minutes=30,
+ reason=UsageLedgerEntry.REASON_ADMIN_ADJUSTMENT,
+ )
+
+ with self.assertRaises(SubscriptionError):
+ start_server_session(user)
+
+ def test_expire_stale_sessions_marks_expired_and_records_usage(self):
+ user = create_user()
+ create_entitlement(user)
+ session = start_server_session(user)
+ session.started_at = timezone.now() - timedelta(minutes=16)
+ session.last_heartbeat_at = timezone.now() - timedelta(minutes=12)
+ session.save(update_fields=['started_at', 'last_heartbeat_at'])
+
+ expired = expire_stale_sessions(max_heartbeat_age_minutes=10)
+
+ self.assertEqual(len(expired), 1)
+ session.refresh_from_db()
+ self.assertEqual(session.status, ServerSession.STATUS_EXPIRED)
+ self.assertEqual(UsageLedgerEntry.objects.get(server_session=session).minutes, 20)
+
+ def test_orchestrator_ready_and_stopped_events_record_usage_once(self):
+ user = create_user()
+ create_entitlement(user)
+ session = start_server_session(user, requested_minutes=60, status=ServerSession.STATUS_LAUNCHING)
+ ready_at = timezone.now() - timedelta(minutes=7)
+
+ process_orchestrator_event(str(session.id), {
+ 'event_id': 'evt-ready',
+ 'event': 'ready',
+ 'occurred_at': ready_at.isoformat(),
+ 'orchestrator_session_id': 'orch-1',
+ 'orchestrator_state': 'running',
+ 'host': 'play.example.test',
+ 'port': 11115,
+ 'password': 'secret',
+ 'ready_at': ready_at.isoformat(),
+ })
+ process_orchestrator_event(str(session.id), {
+ 'event_id': 'evt-stopped',
+ 'event': 'stopped',
+ 'occurred_at': timezone.now().isoformat(),
+ 'orchestrator_session_id': 'orch-1',
+ 'orchestrator_state': 'stopped',
+ 'runtime_seconds': 370,
+ 'reason': 'user_stop',
+ })
+ process_orchestrator_event(str(session.id), {
+ 'event_id': 'evt-stopped',
+ 'event': 'stopped',
+ 'occurred_at': timezone.now().isoformat(),
+ 'orchestrator_session_id': 'orch-1',
+ 'orchestrator_state': 'stopped',
+ 'runtime_seconds': 370,
+ 'reason': 'user_stop',
+ })
+
+ session.refresh_from_db()
+ self.assertEqual(session.status, ServerSession.STATUS_STOPPED)
+ self.assertEqual(session.host, 'play.example.test')
+ self.assertEqual(session.port, 11115)
+ self.assertEqual(session.server_password, 'secret')
+ self.assertEqual(UsageLedgerEntry.objects.get(server_session=session).minutes, 10)
+ self.assertEqual(WebhookEvent.objects.filter(provider='orchestrator').count(), 2)
+
+ def test_orchestrator_launch_failed_does_not_record_usage(self):
+ user = create_user()
+ create_entitlement(user)
+ session = start_server_session(user, requested_minutes=60, status=ServerSession.STATUS_LAUNCHING)
+
+ process_orchestrator_event(str(session.id), {
+ 'event_id': 'evt-failed',
+ 'event': 'launch_failed',
+ 'occurred_at': timezone.now().isoformat(),
+ 'orchestrator_session_id': 'orch-1',
+ 'orchestrator_state': 'failed',
+ 'error_code': 'LAUNCH_FAILED',
+ 'error_message': 'Process exited during startup.',
+ })
+
+ session.refresh_from_db()
+ self.assertEqual(session.status, ServerSession.STATUS_FAILED)
+ self.assertEqual(session.launch_error_code, 'LAUNCH_FAILED')
+ self.assertEqual(UsageLedgerEntry.objects.count(), 0)
+
+
+class SubscriptionApiTests(TestCase):
+ def test_entitlement_endpoint_requires_authentication(self):
+ response = APIClient().get('/api/subscriptions/me/entitlement/')
+
+ self.assertEqual(response.status_code, 401)
+
+ @override_settings(ORCHESTRATOR_API_BASE_URL='https://orchestrator.example.test', ORCHESTRATOR_API_TOKEN='orch-token')
+ @patch('subscriptions.orchestrator.requests.request')
+ def test_user_token_can_launch_and_request_stop(self, mock_request):
+ mock_request.side_effect = [
+ MockResponse(payload={
+ 'id': 'orch-1',
+ 'state': 'starting',
+ 'host': 'play.example.test',
+ 'port': 11115,
+ 'password': 'generated',
+ }),
+ MockResponse(payload={'success': True}),
+ ]
+ user = create_user()
+ create_entitlement(user, tier='host')
+ token = Token.objects.create(user=user)
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f'Token {token.key}')
+
+ response = client.post('/api/subscriptions/server-sessions/start/', {
+ 'requested_minutes': 60,
+ 'game_code': '20',
+ 'game': 'Push Back',
+ 'comment': 'CasualPB',
+ 'launch_context': {'source': 'discord_bot'},
+ }, format='json')
+ self.assertEqual(response.status_code, 201)
+ session_id = response.data['session']['id']
+ self.assertEqual(response.data['session']['status'], ServerSession.STATUS_LAUNCHING)
+ self.assertEqual(response.data['session']['password'], 'generated')
+
+ response = client.post(f'/api/subscriptions/server-sessions/{session_id}/stop/', {
+ 'reason': 'bot_stop',
+ }, format='json')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(ServerSession.objects.get(id=session_id).status, ServerSession.STATUS_STOPPING)
+ self.assertEqual(UsageLedgerEntry.objects.count(), 0)
+
+ @override_settings(
+ ORCHESTRATOR_API_BASE_URL='https://orchestrator.example.test',
+ ORCHESTRATOR_API_TOKEN='orch-token',
+ API_KEY='service-api-key',
+ )
+ @patch('subscriptions.orchestrator.requests.request')
+ def test_api_key_can_launch_for_discord_user(self, mock_request):
+ mock_request.return_value = MockResponse(payload={'id': 'orch-1', 'state': 'starting'})
+ owner = create_user(2002)
+ create_entitlement(owner, tier='host')
+ client = APIClient()
+ client.credentials(HTTP_X_API_KEY='service-api-key')
+
+ response = client.post('/api/subscriptions/server-sessions/start/', {
+ 'owner_discord_id': str(owner.id),
+ 'requested_minutes': 30,
+ 'game_code': '20',
+ 'comment': 'CasualPB',
+ }, format='json')
+
+ self.assertEqual(response.status_code, 201)
+ session = ServerSession.objects.get(id=response.data['session']['id'])
+ self.assertEqual(session.user, owner)
+ self.assertEqual(session.status, ServerSession.STATUS_LAUNCHING)
+
+ @override_settings(API_KEY='service-api-key')
+ def test_api_key_can_read_user_entitlement(self):
+ owner = create_user(2005)
+ create_entitlement(owner, tier='host')
+ client = APIClient()
+ client.credentials(HTTP_X_API_KEY='service-api-key')
+
+ response = client.get(f'/api/subscriptions/users/{owner.id}/entitlement/')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data['discord_user_id'], str(owner.id))
+ self.assertTrue(response.data['entitlement']['active'])
+
+ @override_settings(API_KEY='service-api-key')
+ def test_orchestrator_callback_requires_api_key(self):
+ owner = create_user(3003)
+ create_entitlement(owner)
+ session = start_server_session(owner, status=ServerSession.STATUS_LAUNCHING)
+ client = APIClient()
+ client.credentials(HTTP_X_API_KEY='wrong-key')
+
+ response = client.post(f'/api/subscriptions/server-sessions/{session.id}/orchestrator-event/', {
+ 'event_id': 'evt-ready',
+ 'event': 'ready',
+ 'occurred_at': timezone.now().isoformat(),
+ 'orchestrator_session_id': 'orch-1',
+ 'orchestrator_state': 'running',
+ }, format='json')
+
+ self.assertEqual(response.status_code, 401)
+
+ @override_settings(API_KEY='service-api-key')
+ def test_orchestrator_callback_accepts_api_key(self):
+ owner = create_user(3005)
+ create_entitlement(owner)
+ session = start_server_session(owner, status=ServerSession.STATUS_LAUNCHING)
+ client = APIClient()
+ client.credentials(HTTP_X_API_KEY='service-api-key')
+
+ response = client.post(f'/api/subscriptions/server-sessions/{session.id}/orchestrator-event/', {
+ 'event_id': 'evt-ready',
+ 'event': 'ready',
+ 'occurred_at': timezone.now().isoformat(),
+ 'orchestrator_session_id': 'orch-1',
+ 'orchestrator_state': 'running',
+ }, format='json')
+
+ self.assertEqual(response.status_code, 200)
+
+
+class PolarWebhookTests(TestCase):
+ @override_settings(POLAR_WEBHOOK_SECRET='whsec_' + base64.b64encode(b'secret').decode('utf-8'))
+ def test_standard_webhook_signature_verification_accepts_valid_signature(self):
+ body = b'{"type":"subscription.updated"}'
+ webhook_id = 'evt_123'
+ timestamp = str(int(timezone.now().timestamp()))
+ signed = b'.'.join([webhook_id.encode(), timestamp.encode(), body])
+ signature = 'v1,' + base64.b64encode(hmac.new(b'secret', signed, hashlib.sha256).digest()).decode('utf-8')
+
+ verify_standard_webhook_signature(
+ 'whsec_' + base64.b64encode(b'secret').decode('utf-8'),
+ body,
+ webhook_id,
+ timestamp,
+ signature,
+ )
+
+ @override_settings(POLAR_WEBHOOK_SECRET='plain-secret')
+ def test_polar_subscription_webhook_upserts_entitlement_and_is_idempotent(self):
+ user = create_user()
+ plan = SubscriptionPlan.objects.get(tier='supporter')
+ plan.polar_product_id = 'prod_supporter'
+ plan.save(update_fields=['polar_product_id'])
+ body = {
+ 'type': 'subscription.updated',
+ 'data': {
+ 'id': 'sub_123',
+ 'status': 'active',
+ 'current_period_start': timezone.now().isoformat(),
+ 'current_period_end': (timezone.now() + timedelta(days=30)).isoformat(),
+ 'customer': {'id': 'cus_123', 'external_id': str(user.id)},
+ 'product_id': 'prod_supporter',
+ },
+ }
+ raw_body = json.dumps(body).encode('utf-8')
+ webhook_id = 'evt_123'
+ timestamp = str(int(timezone.now().timestamp()))
+ signed = b'.'.join([webhook_id.encode(), timestamp.encode(), raw_body])
+ signature = 'v1,' + base64.b64encode(hmac.new(b'plain-secret', signed, hashlib.sha256).digest()).decode('utf-8')
+
+ client = APIClient()
+ response = client.post(
+ '/subscriptions/polar/webhook/',
+ data=raw_body,
+ content_type='application/json',
+ HTTP_WEBHOOK_ID=webhook_id,
+ HTTP_WEBHOOK_TIMESTAMP=timestamp,
+ HTTP_WEBHOOK_SIGNATURE=signature,
+ )
+ self.assertEqual(response.status_code, 200)
+
+ response = client.post(
+ '/subscriptions/polar/webhook/',
+ data=raw_body,
+ content_type='application/json',
+ HTTP_WEBHOOK_ID=webhook_id,
+ HTTP_WEBHOOK_TIMESTAMP=timestamp,
+ HTTP_WEBHOOK_SIGNATURE=signature,
+ )
+ self.assertEqual(response.status_code, 200)
+
+ entitlement = SubscriptionEntitlement.objects.get(provider_subscription_id='sub_123')
+ self.assertEqual(entitlement.user, user)
+ self.assertEqual(entitlement.plan.tier, 'supporter')
+ self.assertEqual(WebhookEvent.objects.count(), 1)
diff --git a/subscriptions/urls.py b/subscriptions/urls.py
new file mode 100644
index 0000000..ec1a150
--- /dev/null
+++ b/subscriptions/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from . import views
+
+urlpatterns = [
+ path('', views.subscription_home, name='subscriptions home'),
+ path('checkout//', views.checkout, name='subscriptions checkout'),
+ path('server-sessions/launch/', views.launch_server_session, name='subscriptions launch server'),
+ path('server-sessions//stop/', views.stop_server_session_view, name='subscriptions stop server'),
+ path('polar/webhook/', views.polar_webhook, name='polar webhook'),
+]
diff --git a/subscriptions/views.py b/subscriptions/views.py
new file mode 100644
index 0000000..bc3ff3c
--- /dev/null
+++ b/subscriptions/views.py
@@ -0,0 +1,135 @@
+import json
+
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.http import HttpResponseBadRequest, JsonResponse
+from django.shortcuts import redirect, render
+from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.http import require_POST
+
+from . import orchestrator
+from .models import ServerSession, SubscriptionPlan
+from .services import (
+ SubscriptionError,
+ WebhookVerificationError,
+ create_polar_checkout_url,
+ entitlement_payload,
+ launch_casual_server,
+ process_polar_webhook,
+ request_stop_server_session,
+ verify_standard_webhook_signature,
+)
+
+
+def _get_client_ip(request):
+ forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
+ if forwarded_for:
+ return forwarded_for.split(',')[0].strip()
+ return request.META.get('REMOTE_ADDR', '')
+
+
+@login_required(login_url='/login')
+def subscription_home(request):
+ plans = SubscriptionPlan.objects.filter(is_active=True).order_by('display_order', 'monthly_price_usd', 'name')
+ entitlement = entitlement_payload(request.user)
+ sessions = ServerSession.objects.filter(user=request.user).order_by('-started_at')[:10]
+ casual_games = []
+ games_error = ''
+ if entitlement.get('active') and orchestrator.is_configured():
+ try:
+ casual_games = orchestrator.get_casual_games()
+ except orchestrator.OrchestratorError as exc:
+ games_error = str(exc)
+ return render(request, 'subscriptions/home.html', {
+ 'plans': plans,
+ 'entitlement': entitlement,
+ 'sessions': sessions,
+ 'casual_games': casual_games,
+ 'games_error': games_error,
+ 'orchestrator_configured': orchestrator.is_configured(),
+ 'customer_portal_url': getattr(settings, 'POLAR_CUSTOMER_PORTAL_URL', ''),
+ })
+
+
+@login_required(login_url='/login')
+def checkout(request, tier):
+ try:
+ plan = SubscriptionPlan.objects.get(tier=tier, is_active=True)
+ except SubscriptionPlan.DoesNotExist:
+ messages.error(request, 'That subscription plan is not available.')
+ return redirect('/subscriptions/')
+
+ try:
+ return redirect(create_polar_checkout_url(request.user, plan, customer_ip_address=_get_client_ip(request)))
+ except SubscriptionError as exc:
+ messages.error(request, str(exc))
+ return redirect('/subscriptions/')
+
+
+@login_required(login_url='/login')
+@require_POST
+def launch_server_session(request):
+ try:
+ session = launch_casual_server(
+ request.user,
+ requested_minutes=request.POST.get('requested_minutes'),
+ game=request.POST.get('game_name') or request.POST.get('game_code', ''),
+ game_code=request.POST.get('game_code', ''),
+ comment=request.POST.get('comment', ''),
+ password=request.POST.get('password', ''),
+ launch_source='website',
+ launch_context={'source': 'website'},
+ )
+ except SubscriptionError as exc:
+ messages.error(request, str(exc))
+ return redirect('/subscriptions/')
+
+ messages.success(request, f'Casual server launch requested. Session {session.id} is starting.')
+ return redirect('/subscriptions/')
+
+
+@login_required(login_url='/login')
+@require_POST
+def stop_server_session_view(request, session_id):
+ try:
+ session = ServerSession.objects.get(id=session_id, user=request.user)
+ except ServerSession.DoesNotExist:
+ messages.error(request, 'Server session does not exist.')
+ return redirect('/subscriptions/')
+
+ try:
+ request_stop_server_session(session, reason='website_stop')
+ except SubscriptionError as exc:
+ messages.error(request, str(exc))
+ return redirect('/subscriptions/')
+
+ messages.success(request, 'Server stop requested.')
+ return redirect('/subscriptions/')
+
+
+@csrf_exempt
+@require_POST
+def polar_webhook(request):
+ webhook_id = request.META.get('HTTP_WEBHOOK_ID', '')
+ webhook_timestamp = request.META.get('HTTP_WEBHOOK_TIMESTAMP', '')
+ webhook_signature = request.META.get('HTTP_WEBHOOK_SIGNATURE', '')
+
+ try:
+ verify_standard_webhook_signature(
+ getattr(settings, 'POLAR_WEBHOOK_SECRET', ''),
+ request.body,
+ webhook_id,
+ webhook_timestamp,
+ webhook_signature,
+ )
+ except WebhookVerificationError as exc:
+ return HttpResponseBadRequest(str(exc))
+
+ try:
+ payload = json.loads(request.body.decode('utf-8'))
+ except json.JSONDecodeError:
+ return HttpResponseBadRequest('Invalid JSON payload.')
+
+ event = process_polar_webhook(payload, event_id=webhook_id)
+ return JsonResponse({'success': True, 'status': event.status})
diff --git a/teamleague/migrations/__init__.py b/teamleague/migrations/__init__.py
new file mode 100644
index 0000000..e69de29