Arag / app /superadmin /routers /pricing.py
AuthorBot
Add enterprise growth APIs, billing, admin UI, and test suite.
12930b5
Raw
History Blame Contribute Delete
11.9 kB
"""Author RAG — SuperAdmin Pricing Plan Management.
Routes (all SuperAdmin-only):
GET /api/super/pricing-plans → list all plans
POST /api/super/pricing-plans → create plan
GET /api/super/pricing-plans/{id} → get single plan
PUT /api/super/pricing-plans/{id} → update plan (price, limits, Stripe IDs)
DELETE /api/super/pricing-plans/{id} → deactivate (soft delete)
POST /api/super/pricing-plans/seed → seed default plans (first-time setup)
"""
import json
from datetime import datetime, timezone
import structlog
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_superadmin, get_db
from app.models.pricing_plan import PricingPlan
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/pricing-plans", tags=["SuperAdmin – Pricing"])
# ── Schemas ───────────────────────────────────────────────────────────────────
class PricingPlanCreate(BaseModel):
id: str = Field(..., description="Slug ID e.g. 'starter', 'agency', 'enterprise'")
display_name: str
monthly_price_usd: float = Field(..., ge=0)
annual_price_usd: float = Field(..., ge=0)
stripe_price_id_monthly: str | None = None
stripe_price_id_annual: str | None = None
max_books: int = Field(5, ge=-1)
max_chats_per_month: int = Field(2000, ge=-1)
max_token_budget: int = Field(500_000, ge=1)
max_embeds: int = Field(1, ge=-1)
has_analytics: bool = True
has_ab_testing: bool = False
has_white_label: bool = False
has_api_access: bool = False
has_webhooks: bool = False
has_custom_domain: bool = False
has_priority_support: bool = False
features: list[str] = []
is_active: bool = True
is_popular: bool = False
sort_order: int = 0
overage_price_per_1k_tokens: float = Field(0.003, ge=0)
class PricingPlanUpdate(BaseModel):
display_name: str | None = None
monthly_price_usd: float | None = Field(None, ge=0)
annual_price_usd: float | None = Field(None, ge=0)
stripe_price_id_monthly: str | None = None
stripe_price_id_annual: str | None = None
max_books: int | None = Field(None, ge=-1)
max_chats_per_month: int | None = Field(None, ge=-1)
max_token_budget: int | None = Field(None, ge=1)
max_embeds: int | None = Field(None, ge=-1)
has_analytics: bool | None = None
has_ab_testing: bool | None = None
has_white_label: bool | None = None
has_api_access: bool | None = None
has_webhooks: bool | None = None
has_custom_domain: bool | None = None
has_priority_support: bool | None = None
features: list[str] | None = None
is_active: bool | None = None
is_popular: bool | None = None
sort_order: int | None = None
overage_price_per_1k_tokens: float | None = Field(None, ge=0)
def _plan_to_dict(plan: PricingPlan) -> dict:
return {
"id": plan.id,
"display_name": plan.display_name,
"monthly_price_usd": plan.monthly_price_usd,
"annual_price_usd": plan.annual_price_usd,
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
"stripe_price_id_annual": plan.stripe_price_id_annual,
"max_books": plan.max_books,
"max_chats_per_month": plan.max_chats_per_month,
"max_token_budget": plan.max_token_budget,
"max_embeds": plan.max_embeds,
"has_analytics": plan.has_analytics,
"has_ab_testing": plan.has_ab_testing,
"has_white_label": plan.has_white_label,
"has_api_access": plan.has_api_access,
"has_webhooks": plan.has_webhooks,
"has_custom_domain": plan.has_custom_domain,
"has_priority_support": plan.has_priority_support,
"features": plan.features,
"is_active": plan.is_active,
"is_popular": plan.is_popular,
"sort_order": plan.sort_order,
"overage_price_per_1k_tokens": plan.overage_price_per_1k_tokens,
"created_at": plan.created_at.isoformat() if plan.created_at else None,
"updated_at": plan.updated_at.isoformat() if plan.updated_at else None,
}
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/")
async def list_pricing_plans(
db: AsyncSession = Depends(get_db),
_sa=Depends(get_current_superadmin),
):
"""List all pricing plans (including inactive)."""
result = await db.execute(
select(PricingPlan).order_by(PricingPlan.sort_order)
)
plans = result.scalars().all()
return {"plans": [_plan_to_dict(p) for p in plans]}
@router.post("/", status_code=201)
async def create_pricing_plan(
body: PricingPlanCreate,
db: AsyncSession = Depends(get_db),
_sa=Depends(get_current_superadmin),
):
"""Create a new pricing plan."""
existing = await db.get(PricingPlan, body.id)
if existing:
raise HTTPException(409, f"Plan '{body.id}' already exists")
plan = PricingPlan(
id=body.id,
display_name=body.display_name,
monthly_price_usd=body.monthly_price_usd,
annual_price_usd=body.annual_price_usd,
stripe_price_id_monthly=body.stripe_price_id_monthly,
stripe_price_id_annual=body.stripe_price_id_annual,
max_books=body.max_books,
max_chats_per_month=body.max_chats_per_month,
max_token_budget=body.max_token_budget,
max_embeds=body.max_embeds,
has_analytics=body.has_analytics,
has_ab_testing=body.has_ab_testing,
has_white_label=body.has_white_label,
has_api_access=body.has_api_access,
has_webhooks=body.has_webhooks,
has_custom_domain=body.has_custom_domain,
has_priority_support=body.has_priority_support,
features_json=json.dumps(body.features),
is_active=body.is_active,
is_popular=body.is_popular,
sort_order=body.sort_order,
overage_price_per_1k_tokens=body.overage_price_per_1k_tokens,
)
db.add(plan)
await db.commit()
logger.info("Pricing plan created", plan_id=body.id, monthly=body.monthly_price_usd)
return _plan_to_dict(plan)
@router.get("/{plan_id}")
async def get_pricing_plan(
plan_id: str,
db: AsyncSession = Depends(get_db),
_sa=Depends(get_current_superadmin),
):
"""Get a single pricing plan by ID."""
plan = await db.get(PricingPlan, plan_id)
if not plan:
raise HTTPException(404, f"Plan '{plan_id}' not found")
return _plan_to_dict(plan)
@router.put("/{plan_id}")
async def update_pricing_plan(
plan_id: str,
body: PricingPlanUpdate,
db: AsyncSession = Depends(get_db),
_sa=Depends(get_current_superadmin),
):
"""Update a pricing plan.
Only provided fields are updated. This is the primary tool for:
- Changing prices (monthly_price_usd, annual_price_usd)
- Setting Stripe Price IDs after creating them in Stripe Dashboard
- Adjusting plan limits (max_books, max_chats_per_month, etc.)
- Toggling feature flags (has_white_label, has_api_access, etc.)
"""
plan = await db.get(PricingPlan, plan_id)
if not plan:
raise HTTPException(404, f"Plan '{plan_id}' not found")
update_data = body.model_dump(exclude_none=True)
for field, value in update_data.items():
if field == "features":
plan.features_json = json.dumps(value)
else:
setattr(plan, field, value)
await db.commit()
logger.info("Pricing plan updated", plan_id=plan_id, fields=list(update_data.keys()))
return _plan_to_dict(plan)
@router.delete("/{plan_id}", status_code=200)
async def deactivate_pricing_plan(
plan_id: str,
db: AsyncSession = Depends(get_db),
_sa=Depends(get_current_superadmin),
):
"""Deactivate a plan (hides from public pricing page — does NOT cancel existing subscriptions)."""
plan = await db.get(PricingPlan, plan_id)
if not plan:
raise HTTPException(404, f"Plan '{plan_id}' not found")
plan.is_active = False
await db.commit()
logger.info("Pricing plan deactivated", plan_id=plan_id)
return {"message": f"Plan '{plan_id}' deactivated"}
@router.post("/seed", status_code=201)
async def seed_default_plans(
db: AsyncSession = Depends(get_db),
_sa=Depends(get_current_superadmin),
):
"""Seed the three default plans (Starter / Agency / Enterprise).
Only inserts plans that don't already exist. Safe to call multiple times.
After seeding, paste your Stripe Price IDs via PUT /{plan_id}.
"""
defaults = [
{
"id": "starter",
"display_name": "Starter",
"monthly_price_usd": 49.0,
"annual_price_usd": 39.0,
"max_books": 5,
"max_chats_per_month": 2000,
"max_token_budget": 500_000,
"max_embeds": 1,
"has_analytics": True,
"features_json": json.dumps([
"5 books", "2,000 chats/month",
"1 website embed", "Basic analytics", "Email support",
]),
"is_active": True,
"is_popular": False,
"sort_order": 1,
},
{
"id": "agency",
"display_name": "Agency",
"monthly_price_usd": 499.0,
"annual_price_usd": 399.0,
"max_books": 25,
"max_chats_per_month": 10_000,
"max_token_budget": 3_000_000,
"max_embeds": 3,
"has_analytics": True,
"has_ab_testing": True,
"has_webhooks": True,
"has_priority_support": True,
"features_json": json.dumps([
"25 books", "10,000 chats/month", "3 website embeds",
"Full analytics + A/B testing", "Webhooks",
"Priority support", "99.9% uptime SLA",
]),
"is_active": True,
"is_popular": True,
"sort_order": 2,
},
{
"id": "enterprise",
"display_name": "Enterprise",
"monthly_price_usd": 4999.0,
"annual_price_usd": 3999.0,
"max_books": -1,
"max_chats_per_month": -1,
"max_token_budget": 50_000_000,
"max_embeds": -1,
"has_analytics": True,
"has_ab_testing": True,
"has_white_label": True,
"has_api_access": True,
"has_webhooks": True,
"has_custom_domain": True,
"has_priority_support": True,
"features_json": json.dumps([
"Unlimited books & chats", "Unlimited embeds + API access",
"White-label (remove branding)", "Custom domain",
"Custom integrations & SSO", "Dedicated account manager",
"99.9% uptime SLA",
]),
"is_active": True,
"is_popular": False,
"sort_order": 3,
},
]
created = []
skipped = []
for data in defaults:
existing = await db.get(PricingPlan, data["id"])
if existing:
skipped.append(data["id"])
continue
db.add(PricingPlan(**data))
created.append(data["id"])
if created:
await db.commit()
logger.info("Default pricing plans seeded", created=created, skipped=skipped)
return {
"created": created,
"skipped": skipped,
"message": "Add your Stripe Price IDs via PUT /api/super/pricing-plans/{id}",
}