"""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}", }