Spaces:
Running
Running
| """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 ───────────────────────────────────────────────────────────────── | |
| 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]} | |
| 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) | |
| 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) | |
| 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) | |
| 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"} | |
| 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}", | |
| } | |