Epic 5 Complete: Billing, Subscriptions, and Admin Features

Resolve all 5 deferred items from Epic 5 adversarial code review:
- Migration 124: Add CASCADE to subscriptionstatus enum drop (prevent orphaned references)
- Stripe rate limiting: In-memory per-user limiter (20 calls/60s) on verify-checkout-session
- Subscription request cooldown: 24h cooldown before resubmitting rejected requests
- Token reset date: Initialize on first subscription activation
- Checkout URL validation: Confirmed HTTPS-only (Stripe always returns HTTPS)

Implement Story 5.4 (Usage Tracking & Rate Limit Enforcement):
- Page quota pre-check at HTTP upload layer
- Extend UserRead schema with token quota fields
- Frontend 402 error handling in document upload
- Quota indicator in dashboard sidebar

Story 5.5 (Admin Seed & Approval Flow):
- Seed admin user migration with default credentials warning
- Subscription approval/rejection routes with admin guard
- 24h rejection cooldown enforcement

Story 5.6 (Admin-Only Model Config):
- Global model config visible across all search spaces
- Per-search-space model configs with user access control
- Superuser CRUD for global configs

Additional fixes from code review:
- PageLimitService: PAST_DUE subscriptions enforce free-tier limits
- TokenQuotaService: PAST_DUE subscriptions enforce free-tier limits
- Config routes: Fixed user_id.is_(None) filter on mutation endpoints
- Stripe webhook: Added guard against silent plan downgrade on unrecognized price_id

All changes formatted with Ruff (Python) and Biome (TypeScript).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vonic 2026-04-15 03:54:45 +07:00
parent 20c4f128bb
commit 4eb6ed18d6
41 changed files with 1771 additions and 318 deletions

View file

@ -24,6 +24,7 @@ from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "124"
@ -33,17 +34,35 @@ depends_on: str | Sequence[str] | None = None
# Create the enum type so SQLAlchemy's create_type=False works at runtime
subscriptionstatus_enum = sa.Enum(
"free", "active", "canceled", "past_due",
"free",
"active",
"canceled",
"past_due",
name="subscriptionstatus",
)
def upgrade() -> None:
# Create the PostgreSQL enum type first
subscriptionstatus_enum.create(op.get_bind(), checkfirst=True)
# Drop any pre-existing subscriptionstatus enum (e.g. uppercase version created by
# SQLAlchemy's create_all() during early development) so we can create it with
# the correct lowercase values. Safe to drop here because no column uses it yet.
conn = op.get_bind()
conn.execute(sa.text("DROP TYPE IF EXISTS subscriptionstatus CASCADE"))
# Create the PostgreSQL enum type with lowercase values
subscriptionstatus_enum.create(conn, checkfirst=False)
op.add_column("user", sa.Column("monthly_token_limit", sa.Integer(), nullable=False, server_default="100000"))
op.add_column("user", sa.Column("tokens_used_this_month", sa.Integer(), nullable=False, server_default="0"))
op.add_column(
"user",
sa.Column(
"monthly_token_limit", sa.Integer(), nullable=False, server_default="100000"
),
)
op.add_column(
"user",
sa.Column(
"tokens_used_this_month", sa.Integer(), nullable=False, server_default="0"
),
)
op.add_column("user", sa.Column("token_reset_date", sa.Date(), nullable=True))
op.add_column(
"user",
@ -54,12 +73,23 @@ def upgrade() -> None:
server_default="free",
),
)
op.add_column("user", sa.Column("plan_id", sa.String(50), nullable=False, server_default="free"))
op.add_column("user", sa.Column("stripe_customer_id", sa.String(255), nullable=True))
op.add_column("user", sa.Column("stripe_subscription_id", sa.String(255), nullable=True))
op.add_column(
"user",
sa.Column("plan_id", sa.String(50), nullable=False, server_default="free"),
)
op.add_column(
"user", sa.Column("stripe_customer_id", sa.String(255), nullable=True)
)
op.add_column(
"user", sa.Column("stripe_subscription_id", sa.String(255), nullable=True)
)
op.create_unique_constraint("uq_user_stripe_customer_id", "user", ["stripe_customer_id"])
op.create_unique_constraint("uq_user_stripe_subscription_id", "user", ["stripe_subscription_id"])
op.create_unique_constraint(
"uq_user_stripe_customer_id", "user", ["stripe_customer_id"]
)
op.create_unique_constraint(
"uq_user_stripe_subscription_id", "user", ["stripe_subscription_id"]
)
def downgrade() -> None:

View file

@ -0,0 +1,207 @@
"""126_seed_admin_user
Revision ID: 126
Revises: 125
Create Date: 2026-04-15
Seeds one admin user on fresh installs (no-op if any user already exists).
Credentials are overridable via env vars:
ADMIN_EMAIL (default: admin@surfsense.local)
ADMIN_PASSWORD (default: Admin@SurfSense1)
Admin is created with:
- is_superuser = TRUE, is_active = TRUE, is_verified = TRUE
- subscription_status = 'active', plan_id = 'pro_yearly'
- monthly_token_limit = 1_000_000, pages_limit = 5000
- A default search space, roles, membership, and prompt defaults
"""
from __future__ import annotations
import os
import uuid
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "126"
down_revision: str | None = "125"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _hash_password(password: str) -> str:
"""Hash password using argon2-cffi (installed as a fastapi-users dependency)."""
from argon2 import PasswordHasher
ph = PasswordHasher()
return ph.hash(password)
def upgrade() -> None:
conn = op.get_bind()
# Only seed when the database is empty
result = conn.execute(sa.text('SELECT 1 FROM "user" LIMIT 1'))
if result.fetchone() is not None:
return # Users already exist — skip seed
admin_email = os.environ.get("ADMIN_EMAIL", "admin@surfsense.local")
admin_password = os.environ.get("ADMIN_PASSWORD", "Admin@SurfSense1")
if not os.environ.get("ADMIN_PASSWORD"):
print(
"\n⚠️ WARNING: ADMIN_PASSWORD env var not set. "
"Using default password 'Admin@SurfSense1'. "
"Change this immediately after first login!\n"
)
hashed_pw = _hash_password(admin_password)
admin_id = str(uuid.uuid4())
# 1. Insert admin user
conn.execute(
sa.text(
"""
INSERT INTO "user" (
id, email, hashed_password,
is_active, is_superuser, is_verified,
subscription_status, plan_id,
monthly_token_limit, pages_limit, pages_used,
tokens_used_this_month
) VALUES (
:id, :email, :hashed_password,
TRUE, TRUE, TRUE,
'active', 'pro_yearly',
1000000, 5000, 0,
0
)
"""
),
{
"id": admin_id,
"email": admin_email,
"hashed_password": hashed_pw,
},
)
# 2. Insert default search space for admin (only required columns; defaults handle the rest)
search_space_result = conn.execute(
sa.text(
"""
INSERT INTO searchspaces (name, description, citations_enabled, user_id, created_at)
VALUES ('My Search Space', 'Your personal search space', TRUE, :user_id, now())
RETURNING id
"""
),
{"user_id": admin_id},
)
search_space_id = search_space_result.fetchone()[0]
# 3. Insert default roles for the search space
owner_role_result = conn.execute(
sa.text(
"""
INSERT INTO search_space_roles
(name, description, permissions, is_default, is_system_role, search_space_id, created_at)
VALUES (
'Owner', 'Full access to all search space resources and settings',
ARRAY['*'], FALSE, TRUE, :ss_id, now()
)
RETURNING id
"""
),
{"ss_id": search_space_id},
)
owner_role_id = owner_role_result.fetchone()[0]
conn.execute(
sa.text(
"""
INSERT INTO search_space_roles
(name, description, permissions, is_default, is_system_role, search_space_id, created_at)
VALUES
(
'Editor',
'Can create and update content (no delete, role management, or settings access)',
ARRAY[
'documents:create','documents:read','documents:update',
'chats:create','chats:read','chats:update',
'comments:create','comments:read',
'llm_configs:create','llm_configs:read','llm_configs:update',
'podcasts:create','podcasts:read','podcasts:update',
'video_presentations:create','video_presentations:read','video_presentations:update',
'image_generations:create','image_generations:read',
'vision_configs:create','vision_configs:read',
'connectors:create','connectors:read','connectors:update',
'logs:read', 'members:invite'
],
TRUE, TRUE, :ss_id, now()
),
(
'Viewer', 'Read-only access to search space resources',
ARRAY[
'documents:read','chats:read','comments:read',
'llm_configs:read','podcasts:read','video_presentations:read',
'image_generations:read','vision_configs:read','connectors:read','logs:read'
],
FALSE, TRUE, :ss_id, now()
)
"""
),
{"ss_id": search_space_id},
)
# 4. Insert owner membership
conn.execute(
sa.text(
"""
INSERT INTO search_space_memberships
(user_id, search_space_id, role_id, is_owner, joined_at, created_at)
VALUES (:user_id, :ss_id, :role_id, TRUE, now(), now())
"""
),
{"user_id": admin_id, "ss_id": search_space_id, "role_id": owner_role_id},
)
# 5. Insert default prompts (same as migration 114 but just for admin)
conn.execute(
sa.text(
"""
INSERT INTO prompts
(user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at)
VALUES
(:uid, 'fix-grammar', 'Fix grammar',
'Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}',
'transform'::prompt_mode, 1, false, now()),
(:uid, 'make-shorter', 'Make shorter',
'Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}',
'transform'::prompt_mode, 1, false, now()),
(:uid, 'translate', 'Translate',
'Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}',
'transform'::prompt_mode, 1, false, now()),
(:uid, 'rewrite', 'Rewrite',
'Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}',
'transform'::prompt_mode, 1, false, now()),
(:uid, 'summarize', 'Summarize',
'Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}',
'transform'::prompt_mode, 1, false, now()),
(:uid, 'explain', 'Explain',
'Explain the following text in simple terms:\n\n{selection}',
'explore'::prompt_mode, 1, false, now()),
(:uid, 'ask-knowledge-base', 'Ask my knowledge base',
'Search my knowledge base for information related to:\n\n{selection}',
'explore'::prompt_mode, 1, false, now()),
(:uid, 'look-up-web', 'Look up on the web',
'Search the web for information about:\n\n{selection}',
'explore'::prompt_mode, 1, false, now())
ON CONFLICT (user_id, default_prompt_slug) DO NOTHING
"""
),
{"uid": admin_id},
)
def downgrade() -> None:
# Intentional no-op: never delete users on downgrade
pass

View file

@ -0,0 +1,59 @@
"""127_add_subscription_requests_table
Revision ID: 127
Revises: 126
Create Date: 2026-04-15
Adds the subscription_requests table for admin-approval flow when Stripe
is not configured. Users submit a subscription request; superusers can
approve/reject it from the admin panel.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "127"
down_revision: str | None = "126"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
# Drop any pre-existing enum (e.g. uppercase version from old create_all())
conn.execute(sa.text("DROP TYPE IF EXISTS subscriptionrequeststatus"))
conn.execute(
sa.text(
"CREATE TYPE subscriptionrequeststatus AS ENUM ('pending', 'approved', 'rejected')"
)
)
conn.execute(
sa.text(
"""
CREATE TABLE subscription_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
plan_id VARCHAR(50) NOT NULL,
status subscriptionrequeststatus NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
approved_at TIMESTAMPTZ,
approved_by UUID REFERENCES "user"(id)
)
"""
)
)
conn.execute(
sa.text(
"CREATE INDEX ix_subscription_requests_user_id ON subscription_requests (user_id)"
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS subscription_requests"))
conn.execute(sa.text("DROP TYPE IF EXISTS subscriptionrequeststatus"))

View file

@ -0,0 +1,46 @@
"""128_make_model_config_user_id_nullable
Revision ID: 128
Revises: 127
Create Date: 2026-04-15
Makes user_id nullable on the three model-config tables so that admin-created
(superuser-owned) configurations have user_id = NULL and are visible to all
members of the search space.
Tables affected:
- new_llm_configs
- image_generation_configs
- vision_llm_configs
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "128"
down_revision: str | None = "127"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.alter_column("new_llm_configs", "user_id", existing_type=sa.UUID(), nullable=True)
op.alter_column("image_generation_configs", "user_id", existing_type=sa.UUID(), nullable=True)
op.alter_column("vision_llm_configs", "user_id", existing_type=sa.UUID(), nullable=True)
def downgrade() -> None:
conn = op.get_bind()
# Null out orphaned rows before re-adding NOT NULL (safety guard)
for table in ("new_llm_configs", "image_generation_configs", "vision_llm_configs"):
# If any rows have user_id=NULL we cannot restore NOT NULL — delete them
conn.execute(sa.text(f'DELETE FROM "{table}" WHERE user_id IS NULL'))
op.alter_column("new_llm_configs", "user_id", existing_type=sa.UUID(), nullable=False)
op.alter_column("image_generation_configs", "user_id", existing_type=sa.UUID(), nullable=False)
op.alter_column("vision_llm_configs", "user_id", existing_type=sa.UUID(), nullable=False)

View file

@ -0,0 +1,69 @@
"""129_make_model_config_search_space_id_nullable
Revision ID: 129
Revises: 128
Create Date: 2026-04-15
Makes search_space_id nullable on the three model-config tables so that
admin-created (superuser-owned) configurations have search_space_id = NULL
and are visible to ALL users across ALL search spaces.
Tables affected:
- new_llm_configs
- image_generation_configs
- vision_llm_configs
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "129"
down_revision: str | None = "128"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.alter_column(
"new_llm_configs", "search_space_id", existing_type=sa.Integer(), nullable=True
)
op.alter_column(
"image_generation_configs",
"search_space_id",
existing_type=sa.Integer(),
nullable=True,
)
op.alter_column(
"vision_llm_configs",
"search_space_id",
existing_type=sa.Integer(),
nullable=True,
)
def downgrade() -> None:
conn = op.get_bind()
# Delete global configs (search_space_id IS NULL) before restoring NOT NULL
for table in ("new_llm_configs", "image_generation_configs", "vision_llm_configs"):
conn.execute(sa.text(f'DELETE FROM "{table}" WHERE search_space_id IS NULL'))
op.alter_column(
"new_llm_configs", "search_space_id", existing_type=sa.Integer(), nullable=False
)
op.alter_column(
"image_generation_configs",
"search_space_id",
existing_type=sa.Integer(),
nullable=False,
)
op.alter_column(
"vision_llm_configs",
"search_space_id",
existing_type=sa.Integer(),
nullable=False,
)