1a195a96bd
- Merge origin/main into branch (resolve conflict in integrations_dashboard.html) - Add defensive JSON parsing with try/except for integration.config - Wrap tester() call in try/except to prevent 500 errors from bad config - Add i18n key integrations.connection_test_failed_fallback in en.json - Reference i18n key in template JS fallback message - Update SECURITY_AUDIT.md: add fix date (2026-03-23), update doc date - Remove accidental revert.sh file - Fix missing MagicMock/patch imports in test file - Add tests for invalid JSON config and tester exception error paths Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/daebb70e-059a-4601-8864-88eef49f99cf
289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""REST API for subscription plan CRUD.
|
|
|
|
Endpoints:
|
|
GET /api/plans/ — list active plans (public)
|
|
GET /api/plans/admin — list all plans inc. inactive (admin only)
|
|
POST /api/plans/ — create plan (admin only)
|
|
GET /api/plans/{plan_id} — get single active plan (public)
|
|
PUT /api/plans/{plan_id} — update plan (admin only)
|
|
DELETE /api/plans/{plan_id} — delete plan (admin only)
|
|
POST /api/plans/seed — seed default plans (admin only)
|
|
POST /api/plans/reorder — set sort_order for multiple plans (admin only)
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from typing import Annotated, Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.models import SubscriptionPlan
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/plans", tags=["plans"])
|
|
|
|
DbSession = Annotated[Session, Depends(get_db)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth helper (admin-only)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _require_admin(request: Request) -> dict:
|
|
"""Ensure the caller is an admin. Raises 403 otherwise."""
|
|
user = request.session.get("user")
|
|
if not user or not user.get("is_admin"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
|
return user
|
|
|
|
|
|
AdminUser = Annotated[dict, Depends(_require_admin)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pydantic schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class PlanUpsert(BaseModel):
|
|
"""Body for creating or updating a subscription plan."""
|
|
|
|
name: str
|
|
tagline: str | None = None
|
|
price_monthly: float = 0.0
|
|
price_yearly: float = 0.0
|
|
trial_days: int = 0
|
|
lifetime_file_limit: int = 0
|
|
daily_upload_limit: int = 0
|
|
monthly_upload_limit: int = 0
|
|
max_storage_destinations: int = 0
|
|
max_ocr_pages_monthly: int = 0
|
|
max_file_size_mb: int = 0
|
|
max_mailboxes: int = 0
|
|
overage_percent: int = Field(default=20, ge=0, le=200)
|
|
allow_overage_billing: bool = False
|
|
overage_price_per_doc: float | None = None
|
|
overage_price_per_ocr_page: float | None = None
|
|
is_active: bool = True
|
|
is_highlighted: bool = False
|
|
badge_text: str | None = None
|
|
cta_text: str = "Get started"
|
|
sort_order: int = 0
|
|
features: list[str] = []
|
|
api_access: bool = False
|
|
stripe_price_id_monthly: str | None = None
|
|
stripe_price_id_yearly: str | None = None
|
|
|
|
|
|
class ReorderBody(BaseModel):
|
|
"""Body for reordering plans."""
|
|
|
|
order: list[str]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _plan_to_response(plan: SubscriptionPlan) -> dict[str, Any]:
|
|
features: list[str] = []
|
|
if plan.features:
|
|
try:
|
|
features = json.loads(plan.features)
|
|
except (json.JSONDecodeError, TypeError):
|
|
features = []
|
|
return {
|
|
"id": plan.id,
|
|
"plan_id": plan.plan_id,
|
|
"name": plan.name,
|
|
"tagline": plan.tagline,
|
|
"price_monthly": plan.price_monthly,
|
|
"price_yearly": plan.price_yearly,
|
|
"trial_days": plan.trial_days,
|
|
"lifetime_file_limit": plan.lifetime_file_limit,
|
|
"daily_upload_limit": plan.daily_upload_limit,
|
|
"monthly_upload_limit": plan.monthly_upload_limit,
|
|
"max_storage_destinations": plan.max_storage_destinations,
|
|
"max_ocr_pages_monthly": plan.max_ocr_pages_monthly,
|
|
"max_file_size_mb": plan.max_file_size_mb,
|
|
"max_mailboxes": plan.max_mailboxes,
|
|
"overage_percent": plan.overage_percent,
|
|
"allow_overage_billing": plan.allow_overage_billing,
|
|
"overage_price_per_doc": plan.overage_price_per_doc,
|
|
"overage_price_per_ocr_page": plan.overage_price_per_ocr_page,
|
|
"is_active": plan.is_active,
|
|
"is_highlighted": plan.is_highlighted,
|
|
"badge_text": plan.badge_text,
|
|
"cta_text": plan.cta_text,
|
|
"sort_order": plan.sort_order,
|
|
"features": features,
|
|
"api_access": plan.api_access,
|
|
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
|
|
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
|
|
"created_at": plan.created_at.isoformat() if plan.created_at else None,
|
|
"updated_at": plan.updated_at.isoformat() if plan.updated_at else None,
|
|
}
|
|
|
|
|
|
def _apply_body(plan: SubscriptionPlan, body: PlanUpsert) -> None:
|
|
"""Apply PlanUpsert fields onto a SubscriptionPlan ORM object."""
|
|
plan.name = body.name
|
|
plan.tagline = body.tagline
|
|
plan.price_monthly = body.price_monthly
|
|
plan.price_yearly = body.price_yearly
|
|
plan.trial_days = body.trial_days
|
|
plan.lifetime_file_limit = body.lifetime_file_limit
|
|
plan.daily_upload_limit = body.daily_upload_limit
|
|
plan.monthly_upload_limit = body.monthly_upload_limit
|
|
plan.max_storage_destinations = body.max_storage_destinations
|
|
plan.max_ocr_pages_monthly = body.max_ocr_pages_monthly
|
|
plan.max_file_size_mb = body.max_file_size_mb
|
|
plan.max_mailboxes = body.max_mailboxes
|
|
plan.overage_percent = body.overage_percent
|
|
plan.allow_overage_billing = body.allow_overage_billing
|
|
plan.overage_price_per_doc = body.overage_price_per_doc
|
|
plan.overage_price_per_ocr_page = body.overage_price_per_ocr_page
|
|
plan.is_active = body.is_active
|
|
plan.is_highlighted = body.is_highlighted
|
|
plan.badge_text = body.badge_text
|
|
plan.cta_text = body.cta_text
|
|
plan.sort_order = body.sort_order
|
|
plan.features = json.dumps(body.features)
|
|
plan.api_access = body.api_access
|
|
plan.stripe_price_id_monthly = body.stripe_price_id_monthly or None
|
|
plan.stripe_price_id_yearly = body.stripe_price_id_yearly or None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/", summary="List active plans (public)")
|
|
def list_active_plans(db: DbSession) -> dict[str, Any]:
|
|
"""Return all active plans in sort order. Public endpoint — no auth required."""
|
|
plans = (
|
|
db.query(SubscriptionPlan)
|
|
.filter(SubscriptionPlan.is_active.is_(True))
|
|
.order_by(SubscriptionPlan.sort_order)
|
|
.all()
|
|
)
|
|
return {"plans": [_plan_to_response(p) for p in plans]}
|
|
|
|
|
|
@router.get("/admin", summary="List all plans including inactive (admin only)")
|
|
def list_all_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
|
"""Return all plans (active and inactive) in sort order. Admin only."""
|
|
plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.sort_order).all()
|
|
return {"plans": [_plan_to_response(p) for p in plans]}
|
|
|
|
|
|
@router.post("/seed", summary="Seed default plans (admin only)", status_code=status.HTTP_200_OK)
|
|
def seed_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
|
"""Seed the subscription_plans table from TIER_DEFAULTS. No-op if plans already exist."""
|
|
from app.utils.subscription import seed_default_plans
|
|
|
|
inserted = seed_default_plans(db)
|
|
return {"inserted": inserted, "message": f"Seeded {inserted} default plan(s)."}
|
|
|
|
|
|
@router.post("/reorder", summary="Reorder plans (admin only)")
|
|
def reorder_plans(body: ReorderBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
|
"""Update sort_order for each plan_id in *body.order* (position = index in list)."""
|
|
updated = 0
|
|
|
|
# Fetch all requested plans in a single query to avoid N+1
|
|
plan_ids = body.order
|
|
plans = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id.in_(plan_ids)).all()
|
|
|
|
# Build a map for fast O(1) lookup
|
|
plan_map = {p.plan_id: p for p in plans}
|
|
|
|
for sort_order, plan_id in enumerate(plan_ids):
|
|
plan = plan_map.get(plan_id)
|
|
if plan:
|
|
plan.sort_order = sort_order
|
|
updated += 1
|
|
|
|
try:
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reorder plans")
|
|
return {"updated": updated}
|
|
|
|
|
|
@router.post("/", summary="Create a new plan (admin only)", status_code=status.HTTP_201_CREATED)
|
|
def create_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
|
"""Create a new subscription plan with the given *plan_id* slug."""
|
|
existing = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Plan '{plan_id}' already exists.",
|
|
)
|
|
plan = SubscriptionPlan(plan_id=plan_id)
|
|
_apply_body(plan, body)
|
|
db.add(plan)
|
|
try:
|
|
db.commit()
|
|
db.refresh(plan)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
logger.info("Admin created subscription plan '%s'", plan_id)
|
|
return _plan_to_response(plan)
|
|
|
|
|
|
@router.get("/{plan_id}", summary="Get a single active plan (public)")
|
|
def get_plan(plan_id: str, db: DbSession) -> dict[str, Any]:
|
|
"""Return a single active plan by plan_id. Public endpoint."""
|
|
plan = (
|
|
db.query(SubscriptionPlan)
|
|
.filter(
|
|
SubscriptionPlan.plan_id == plan_id,
|
|
SubscriptionPlan.is_active.is_(True),
|
|
)
|
|
.first()
|
|
)
|
|
if not plan:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.")
|
|
return _plan_to_response(plan)
|
|
|
|
|
|
@router.put("/{plan_id}", summary="Update an existing plan (admin only)")
|
|
def update_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
|
"""Update an existing subscription plan. Admin only."""
|
|
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
|
|
if not plan:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.")
|
|
_apply_body(plan, body)
|
|
try:
|
|
db.commit()
|
|
db.refresh(plan)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
logger.info("Admin updated subscription plan '%s'", plan_id)
|
|
return _plan_to_response(plan)
|
|
|
|
|
|
@router.delete("/{plan_id}", summary="Delete a plan (admin only)", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_plan(plan_id: str, db: DbSession, _admin: AdminUser) -> None:
|
|
"""Delete a subscription plan. Admin only."""
|
|
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
|
|
if not plan:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.")
|
|
try:
|
|
db.delete(plan)
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
logger.info("Admin deleted subscription plan '%s'", plan_id)
|